> ## Documentation Index
> Fetch the complete documentation index at: https://developers.perkstar.co.uk/llms.txt
> Use this file to discover all available pages before exploring further.

# Reverse a previously-posted transaction

> Creates a compensating Transaction row whose `delta` is the
negation of the original's, linked via
`reverses_transaction_id`. Fires `TRANSACTION_CREATED` so
partner webhooks notice the reversal end-to-end.

Idempotent: reversing the same transaction twice returns the
existing reversal at HTTP 200 (enforced by the unique
constraint on `Transaction.reversesTransactionId` + a P2002
fallback).

Cannot reverse:
  • An already-reversed transaction's reversal (post a new
    accrue instead).
  • A transaction in the opposite mode — live keys can't
    reverse test rows and vice versa.

Pass either `transaction_id` (Perkstar's id) OR
`external_transaction_id` (your POS-side id). Both forms
resolve to the same underlying row.




## OpenAPI

````yaml /api/openapi.yaml post /marketplace/reverse
openapi: 3.1.0
info:
  title: Perkstar Public REST API
  version: 2026-05-04.oauth
  description: |
    Programmatic access to your Perkstar loyalty data. Two authentication
    modes are supported:

    - **API key.** Per-org bearer token issued from Settings → API keys
      (`pk_live_…` / `pk_test_…`). Right choice for direct integrations,
      single-tenant POS adapters, and back-office sync.
    - **OAuth 2.0.** Three-legged authorization-code flow with PKCE,
      for marketplace listings (Square App Marketplace, Shopify App
      Store, Toast Partner Marketplace, Lightspeed). The merchant
      installs your app from the partner marketplace, lands on
      `/oauth/authorize`, approves the requested scopes, and your
      server exchanges the code at `/api/oauth/token` for a short-lived
      access token. See the dedicated OAuth section below.

    Every endpoint is scoped, rate-limited, and idempotent on POST.
    POS-adapter builders should start with the `/marketplace/*`
    endpoints — they collapse the typical "find-or-create customer +
    enrolment + post transaction" flow into a single round-trip with
    permanent dedupe via `external_transaction_id`.

    ## Test credentials

    Test credentials (`pk_test_…` or OAuth applications in TEST mode)
    can read API data, create isolated test transactions, and simulate
    wallet pushes. They cannot create, update, or delete live customers,
    enrolments, or webhook configuration. Test calls to
    `/marketplace/accrue` only work with an existing customer and
    enrolment; `/marketplace/enroll` is live-only. This prevents a
    sandbox integration from changing live customer records.

    Copy-paste cURL recipes are published at `/api/v1/curl-recipes.md`
    for teams that want to test the API before wiring the SDK.

    ## Response headers (universal)

    Every response, success or error, carries:

    - **X-Request-Id** — unique per request. Echo this in support
      tickets; we can find the exact call in the per-key audit log.
    - **X-API-Version** — date-versioned schema marker
      (`2026-05-04` as of writing). 12-month deprecation policy.
    - **X-RateLimit-Limit** — per-minute quota for the credential.
    - **X-RateLimit-Remaining** — tokens left in the current window.
    - **X-RateLimit-Reset** — unix-seconds when the window refills.
    - **Retry-After** — emitted on 429 only; seconds to wait.

    The reusable error responses below (`Unauthorized`, `Forbidden`,
    `NotFound`, `RateLimited`, `ValidationError`) declare these as
    strongly-typed `headers` so codegen against an error path has
    them. Success responses inline their `"200"` / `"201"` and rely
    on this convention rather than per-endpoint header declarations.
  contact:
    name: Perkstar
    url: https://perkstar.co.uk
servers:
  - url: https://dashboard.perkstar.co.uk/api/v1
    description: Production
security:
  - bearerAuth: []
paths:
  /marketplace/reverse:
    post:
      tags:
        - Marketplace
      summary: Reverse a previously-posted transaction
      description: |
        Creates a compensating Transaction row whose `delta` is the
        negation of the original's, linked via
        `reverses_transaction_id`. Fires `TRANSACTION_CREATED` so
        partner webhooks notice the reversal end-to-end.

        Idempotent: reversing the same transaction twice returns the
        existing reversal at HTTP 200 (enforced by the unique
        constraint on `Transaction.reversesTransactionId` + a P2002
        fallback).

        Cannot reverse:
          • An already-reversed transaction's reversal (post a new
            accrue instead).
          • A transaction in the opposite mode — live keys can't
            reverse test rows and vice versa.

        Pass either `transaction_id` (Perkstar's id) OR
        `external_transaction_id` (your POS-side id). Both forms
        resolve to the same underlying row.
      operationId: marketplaceReverse
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MarketplaceReverseRequest'
      responses:
        '200':
          description: Existing reversal returned (idempotent replay)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MarketplaceReverseResponse'
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MarketplaceReverseResponse'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'
        '429':
          $ref: '#/components/responses/RateLimited'
      security:
        - bearerAuth: []
components:
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      schema:
        type: string
        maxLength: 255
      description: |
        Replay-safe request key. Repeated requests with the same value
        within 24h return the original response unchanged.
  schemas:
    MarketplaceReverseRequest:
      type: object
      description: |
        Exactly one of `transaction_id` or `external_transaction_id`
        must be supplied.
      properties:
        transaction_id:
          type: string
        external_transaction_id:
          type: string
        reason:
          type: string
          maxLength: 280
    MarketplaceReverseResponse:
      type: object
      properties:
        transaction:
          $ref: '#/components/schemas/Transaction'
        reverses_transaction_id:
          type: string
        enrollment:
          type: object
          properties:
            id:
              type: string
            balance:
              type: number
              multipleOf: 0.01
            redemptions_count:
              type: integer
        idempotent_replay:
          type: boolean
          description: |
            Set to true when the response is a replay of an existing
            reversal. Absent on fresh creates.
    Transaction:
      type: object
      properties:
        id:
          type: string
        enrollment_id:
          type: string
        type:
          type: string
          enum:
            - STAMP
            - REDEEM
            - ADJUST
        source:
          type: string
          enum:
            - SCANNER
            - POS
            - API
            - ADJUST
            - DASHBOARD
            - LEGACY_IMPORT
            - TAP
        delta:
          type: number
          multipleOf: 0.01
        amount_pence:
          type: integer
          nullable: true
        note:
          type: string
          nullable: true
        external_id:
          type: string
          nullable: true
        external_transaction_id:
          type: string
          nullable: true
          description: |
            Partner-supplied stable identifier (Toast order id,
            Shopify checkout id, Square payment id). When set on
            create via `POST /transactions` or
            `POST /marketplace/accrue`, the unique
            `(orgId, externalTransactionId)` index makes the call
            permanently idempotent — a redelivered upstream webhook
            returns the existing row at HTTP 200 instead of
            double-crediting.
        reverses_transaction_id:
          type: string
          nullable: true
          description: |
            Set on a compensating row created by
            `/marketplace/reverse`. Points at the original transaction
            this row reverses. The original is reversed at most once
            (enforced by a unique constraint).
        reason_text:
          type: string
          nullable: true
          description: |
            Operator-visible reason for a reversal ("Customer refunded
            the order"). Persisted on the COMPENSATING row, not the
            original.
        test_mode:
          type: boolean
          description: |
            True when posted via a `pk_test_…` key. Test rows still
            write to the ledger and still fire webhooks (so partner
            sandbox integrations can verify their receivers), but DO
            NOT update Customer / Card / Enrollment aggregates and
            are excluded from operator dashboard rollups.
        created_at:
          type: string
          format: date-time
    Error:
      type: object
      properties:
        error:
          type: object
          required:
            - type
            - message
            - code
          properties:
            type:
              type: string
              enum:
                - authentication_error
                - permission_error
                - rate_limit_error
                - validation_error
                - not_found
                - idempotency_error
                - server_error
            message:
              type: string
            code:
              type: string
            param:
              type: string
  responses:
    ValidationError:
      description: Validation failed
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-API-Version:
          $ref: '#/components/headers/XAPIVersion'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing / invalid / expired API key
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-API-Version:
          $ref: '#/components/headers/XAPIVersion'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: API key is missing the required scope
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-API-Version:
          $ref: '#/components/headers/XAPIVersion'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Resource not found
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-API-Version:
          $ref: '#/components/headers/XAPIVersion'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Per-key rate limit exceeded
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        X-API-Version:
          $ref: '#/components/headers/XAPIVersion'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  headers:
    XRequestId:
      description: |
        Unique id for this request. Echo this in your support tickets
        and we can trace the exact call in the audit log on the API key
        detail page.
      schema:
        type: string
    XAPIVersion:
      description: |
        Date-versioned schema marker. The current value is `2026-05-04`.
        Bumps follow the 12-month deprecation policy documented in the
        in-dashboard docs page.
      schema:
        type: string
    XRateLimitLimit:
      description: Total requests permitted per minute for this credential.
      schema:
        type: integer
    XRateLimitRemaining:
      description: Requests left in the current 60s window.
      schema:
        type: integer
    XRateLimitReset:
      description: Unix-seconds timestamp when the current window refills.
      schema:
        type: integer
    RetryAfter:
      description: |
        Seconds to wait before retrying. Only emitted on 429 responses.
      schema:
        type: integer
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: pk_live_… / pk_test_… or perk_at_…

````

## Related topics

- [List transactions](/api-reference/transactions/list-transactions.md)
- [Post a transaction (stamp / redeem / adjust)](/api-reference/transactions/post-a-transaction-stamp-redeem-adjust.md)
- [Find-or-create customer + post transaction in one call](/api-reference/marketplace/find-or-create-customer-+-post-transaction-in-one-call.md)
- [Idempotency and retries](/fundamentals/idempotency.md)
- [Go-live checklist](/support/go-live-checklist.md)
