> ## Documentation Index
> Fetch the complete documentation index at: https://docs.loyalty.xeniamoments.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Record sale outcome

> Sent by the OnlinePOS backend exactly once per `calculationId`, after the transaction is finalised
or the payment is abandoned. Never blocks checkout. Replaces Version 1's
`loyaltySaleFinalization`, `loyaltySaleCancellation` and `loyaltyOfflineSale`.

Retry on `429`, `5xx` and network errors with exponential backoff (up to 24 h) and the same
`Idempotency-Key`. Stop on any other `4xx`.

The REKOM backend always answers `200` for a well-formed, authenticated event, including for a
`calculationId` it does not recognise (`result: unknown_calculation`), so that the sender never retries forever.

Field names and structures are directional (see the API description).




## OpenAPI

````yaml /api-reference/openapi.yaml post /v1/onlinepos/sale
openapi: 3.1.0
info:
  title: REKOM Loyalty Partner API (for OnlinePOS)
  version: 0.1.0-draft
  summary: >-
    Synchronous basket calculation and post-sale events between OnlinePOS and
    the REKOM backend.
  description: >
    Draft contract for the two endpoints the REKOM backend exposes to OnlinePOS.


    - `POST /v1/onlinepos/basket` — calculate the loyalty basket for a
    recognised member (synchronous, inside the payment motion).

    - `POST /v1/onlinepos/sale` — report the outcome of a calculation once the
    sale is finalised or abandoned.


    Money is in integer minor units with an ISO 4217 currency. All requests are
    signed with

    `OnlinePOS-Signature` (HMAC-SHA256) and carry an `Idempotency-Key` equal to
    the `calculationId`.

    Status: **draft v0.1, directional** (REKOM, 8 Oct 2026). Every field name,
    header, enum value and

    structure here shows which information is exchanged; none has been aligned
    with OnlinePOS. REKOM will

    adopt OnlinePOS's default names and structures for the order object and
    webhooks and does not require

    any of these names. Hosts are TBC.
  contact:
    name: REKOM
servers:
  - url: https://loyalty.xeniamoments.com
    description: Production (TBC)
  - url: https://loyalty-staging.xeniamoments.com
    description: Staging / test (TBC)
security:
  - OnlinePosSignature: []
tags:
  - name: Basket
    description: Synchronous loyalty basket calculation.
  - name: Sale
    description: Post-sale outcome per calculation.
paths:
  /v1/onlinepos/sale:
    post:
      tags:
        - Sale
      summary: Record sale outcome
      description: >
        Sent by the OnlinePOS backend exactly once per `calculationId`, after
        the transaction is finalised

        or the payment is abandoned. Never blocks checkout. Replaces Version 1's

        `loyaltySaleFinalization`, `loyaltySaleCancellation` and
        `loyaltyOfflineSale`.


        Retry on `429`, `5xx` and network errors with exponential backoff (up to
        24 h) and the same

        `Idempotency-Key`. Stop on any other `4xx`.


        The REKOM backend always answers `200` for a well-formed, authenticated
        event, including for a

        `calculationId` it does not recognise (`result: unknown_calculation`),
        so that the sender never retries forever.


        Field names and structures are directional (see the API description).
      operationId: recordSale
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SaleEvent'
            examples:
              applied:
                summary: applied — returned basket was charged
                value:
                  calculationId: 6f1d2c3e-8a4b-4c5d-9e0f-1a2b3c4d5e6f
                  status: applied
                  transactionId: '987654321'
                  receiptNumber: 10234
                  loyaltyId: rk_8f2a1c9d4b7e
                  currency: DKK
                  paidTotal: 16400
                  loyaltyDiscountTotal: 2600
                  occurredAt: '2026-10-08T21:14:09+02:00'
                  context:
                    venueId: '123'
                    cashRegisterId: '7'
                    baxId: '601553'
                    terminalId: '1001'
                    clerkNumber: 12
                    channel: pos
              failedTimeout:
                summary: failed / timeout — original basket was charged
                value:
                  calculationId: 2c9e4b1a-7d3f-4a8e-b5c6-0d1e2f3a4b5c
                  status: failed
                  failureReason: timeout
                  transactionId: '987654322'
                  receiptNumber: 10235
                  loyaltyId: rk_8f2a1c9d4b7e
                  currency: DKK
                  paidTotal: 19000
                  loyaltyDiscountTotal: 0
                  occurredAt: '2026-10-08T21:20:41+02:00'
                  context:
                    venueId: '123'
                    cashRegisterId: '7'
                    baxId: '601553'
                    terminalId: '1001'
                    clerkNumber: 12
                    channel: pos
              cancelled:
                summary: cancelled — payment abandoned after calculation
                value:
                  calculationId: 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d
                  status: cancelled
                  loyaltyId: rk_8f2a1c9d4b7e
                  currency: DKK
                  occurredAt: '2026-10-08T21:31:12+02:00'
                  context:
                    venueId: '123'
                    cashRegisterId: '7'
                    baxId: '601553'
                    terminalId: '1001'
                    clerkNumber: 12
                    channel: pos
      responses:
        '200':
          description: Event accepted.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/XRequestId'
            Server-Timing:
              $ref: '#/components/headers/ServerTiming'
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SaleResponse'
              examples:
                recorded:
                  value:
                    calculationId: 6f1d2c3e-8a4b-4c5d-9e0f-1a2b3c4d5e6f
                    result: recorded
                duplicate:
                  value:
                    calculationId: 6f1d2c3e-8a4b-4c5d-9e0f-1a2b3c4d5e6f
                    result: duplicate
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/Unavailable'
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >-
        The `calculationId`. Same key with the same body replays the stored
        response; same key with a different body is rejected with `409`.
      schema:
        type: string
        format: uuid
    RequestId:
      name: X-Request-Id
      in: header
      required: false
      description: Fresh UUID per HTTP attempt, echoed in the response for log correlation.
      schema:
        type: string
        format: uuid
  schemas:
    SaleEvent:
      type: object
      description: >
        Outcome of one calculation, sent exactly once per `calculationId`.

        `transactionId` is required for every status except `cancelled`;
        `failureReason` is required when `status` is `failed`.
      required:
        - calculationId
        - status
        - currency
        - occurredAt
        - context
      properties:
        calculationId:
          $ref: '#/components/schemas/Uuid'
        status:
          type: string
          enum:
            - applied
            - unchanged
            - member_not_found
            - failed
            - cancelled
          description: >
            - `applied` — returned basket charged.

            - `unchanged` — no-change response; original basket charged.

            - `member_not_found` — no member; original basket charged.

            - `failed` — call failed or response rejected; original basket
            charged.

            - `cancelled` — payment abandoned after calculation; nothing
            charged.
        failureReason:
          type: string
          enum:
            - timeout
            - connection_error
            - http_error
            - invalid_response
            - offline
            - pos_error
          description: Required when `status` is `failed`.
        transactionId:
          type: string
          description: OnlinePOS transaction id (`transaction_id`). Absent for `cancelled`.
          examples:
            - '987654321'
        receiptNumber:
          type: integer
          description: OnlinePOS receipt number (`receipt_number`).
          examples:
            - 10234
        loyaltyId:
          $ref: '#/components/schemas/LoyaltyId'
        currency:
          $ref: '#/components/schemas/Currency'
        paidTotal:
          allOf:
            - $ref: '#/components/schemas/Money'
          description: Amount actually charged. Absent for `cancelled`.
        loyaltyDiscountTotal:
          allOf:
            - $ref: '#/components/schemas/Money'
          description: Loyalty discount on the finalised transaction (0 unless `applied`).
        occurredAt:
          type: string
          format: date-time
          description: Time the transaction was finalised or the payment abandoned.
          examples:
            - '2026-10-08T21:14:09+02:00'
        context:
          $ref: '#/components/schemas/Context'
    SaleResponse:
      type: object
      required:
        - calculationId
        - result
      properties:
        calculationId:
          $ref: '#/components/schemas/Uuid'
        result:
          type: string
          enum:
            - recorded
            - duplicate
            - unknown_calculation
          description: >
            - `recorded` — stored.

            - `duplicate` — an event for this `calculationId` was already
            stored; nothing changed.

            - `unknown_calculation` — REKOM has no calculation with this GUID;
            the event is stored for reconciliation. Final; do not retry.
    Uuid:
      type: string
      format: uuid
      description: Lower-case UUID with hyphens (36 characters).
      examples:
        - 6f1d2c3e-8a4b-4c5d-9e0f-1a2b3c4d5e6f
    LoyaltyId:
      type: string
      minLength: 1
      maxLength: 64
      description: >
        Opaque REKOM member identifier as returned by Nexi Engage (`getasset` /
        Softpay). Contains no PII.

        Exact format pending Nexi confirmation; treat as an opaque string.
      examples:
        - rk_8f2a1c9d4b7e
    Currency:
      type: string
      pattern: ^[A-Z]{3}$
      description: ISO 4217 currency code.
      examples:
        - DKK
        - NOK
        - SEK
        - EUR
    Money:
      type: integer
      minimum: 0
      description: >-
        Amount in integer minor units of the basket `currency` (øre for DKK).
        Never negative, never fractional.
      examples:
        - 6500
    Context:
      type: object
      description: >
        Where the basket is being sold. Field meanings mirror the OnlinePOS REST
        transaction API so reconciliation is a join.

        `baxId` and `cashRegisterId` are required so REKOM can attribute every
        order to a venue and a till (agreed after 7 Oct 2026).
      required:
        - venueId
        - baxId
        - cashRegisterId
        - channel
      properties:
        venueId:
          type: string
          description: OnlinePOS venue id (`venue` in the REST API).
          examples:
            - '123'
        cashRegisterId:
          type: string
          description: >-
            OnlinePOS terminal setup / till id (`cash_register_id`). One
            terminal setup corresponds to one BAX.
          examples:
            - '7'
        baxId:
          type: string
          description: >-
            BAX number of the terminal setup; equals the Nexi Engage `storeId`.
            Identifies the venue for REKOM. Required on both channels.
          examples:
            - '601553'
        terminalId:
          type: string
          description: >-
            Payment terminal identifier (TID) that performed the lookup and
            payment, when the POS knows it.
          examples:
            - '1001'
        clerkNumber:
          type: integer
          description: Clerk number of the staff member (`clerk_number`).
          examples:
            - 12
        channel:
          $ref: '#/components/schemas/Channel'
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - requestId
          properties:
            code:
              type: string
              enum:
                - invalid_request
                - invalid_signature
                - stale_timestamp
                - idempotency_conflict
                - arithmetic_mismatch
                - unknown_currency
                - venue_not_configured
                - too_many_lines
                - rate_limited
                - internal_error
                - unavailable
            message:
              type: string
              description: Human-readable, for logs. Not shown to staff or guests.
            details:
              type: object
              additionalProperties: true
              description: Optional machine-readable detail (field, expected, received).
            requestId:
              type: string
              format: uuid
    Channel:
      type: string
      enum:
        - pos
        - mpos
      description: '`pos` = Windows POS (BAXI/Viking), `mpos` = Android mPOS (Softpay).'
  headers:
    XRequestId:
      description: Echo of the request's `X-Request-Id`, or a server-generated UUID.
      schema:
        type: string
        format: uuid
    ServerTiming:
      description: Server processing time, e.g. `app;dur=42`.
      schema:
        type: string
        examples:
          - app;dur=42
    IdempotentReplayed:
      description: >-
        Present and `true` when the response was served from the idempotency
        store.
      schema:
        type: boolean
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer
        minimum: 1
  responses:
    BadRequest:
      description: Malformed JSON or schema violation.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: invalid_request
              message: lines[0].quantity must be a number.
              requestId: 0b7a7a4e-0c1b-4e2a-9f3d-5c6d7e8f9a0b
    Unauthorized:
      description: Signature missing, invalid, or timestamp outside the ±300 s window.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalidSignature:
              value:
                error:
                  code: invalid_signature
                  message: Signature does not verify.
                  requestId: 0b7a7a4e-0c1b-4e2a-9f3d-5c6d7e8f9a0b
            staleTimestamp:
              value:
                error:
                  code: stale_timestamp
                  message: Signature timestamp is 412 s from server time (max 300 s).
                  requestId: 0b7a7a4e-0c1b-4e2a-9f3d-5c6d7e8f9a0b
    IdempotencyConflict:
      description: The `Idempotency-Key` was already used with a different body.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: idempotency_conflict
              message: >-
                Idempotency-Key 6f1d2c3e-… was used with a different request
                body.
              requestId: 0b7a7a4e-0c1b-4e2a-9f3d-5c6d7e8f9a0b
    RateLimited:
      description: Too many requests.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: rate_limited
              message: Rate limit exceeded; retry after 2 s.
              requestId: 0b7a7a4e-0c1b-4e2a-9f3d-5c6d7e8f9a0b
    InternalError:
      description: Unexpected error on REKOM's side.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: internal_error
              message: Unexpected error.
              requestId: 0b7a7a4e-0c1b-4e2a-9f3d-5c6d7e8f9a0b
    Unavailable:
      description: Maintenance or overload.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/XRequestId'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: unavailable
              message: Service temporarily unavailable.
              requestId: 0b7a7a4e-0c1b-4e2a-9f3d-5c6d7e8f9a0b
  securitySchemes:
    OnlinePosSignature:
      type: apiKey
      in: header
      name: OnlinePOS-Signature
      description: >
        HMAC-SHA256 request signature: `t=<unix seconds>,v1=<hex>` where

        `v1 = HMAC_SHA256(secret, "<t>.<raw body>")`. Timestamp window ±300 s.

        One secret per environment. No scheduled rotation; when a secret is
        swapped, old and new stay

        active with an overlap so nothing is interrupted. See Configuration and
        security.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.