> ## 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.

# Build a POS integration

> Walk through a complete till journey: connect, identify, earn, display, reverse, and reconcile.

Start with [one tested stamp request and webhook receipt](/quickstart) before
building a complete POS adapter.

This walkthrough models the customer and cashier journey as well as the API
calls behind it. It uses the marketplace endpoints because one request can
resolve the customer, select the programme, find or create the enrolment, and
post loyalty safely.

<CardGroup cols={3}>
  <Card title="1. Connect" icon="plug">
    Confirm the business and choose the exact card and location.
  </Card>

  <Card title="2. Transact" icon="cash-register">
    Scan or match the customer, then accrue loyalty with durable IDs.
  </Card>

  <Card title="3. Recover" icon="rotate-left">
    Reverse refunds, retry timeouts, and reconcile missed events.
  </Card>
</CardGroup>

## Choose the installation model

<Tabs>
  <Tab title="One business">
    Create a dedicated API key in that business's dashboard. This is the
    simplest model for an in-house connector, one Odoo database, or one POS
    merchant.

    Grant `MARKETPLACE`, plus `LOCATIONS_READ` when the integration maps stores.
    Add separate read scopes only for reconciliation jobs that need them.
  </Tab>

  <Tab title="Marketplace app">
    Use OAuth when the same application will be installed by many businesses.
    Each merchant consents to one organisation and your backend stores a
    separate token set for it.

    Follow the [OAuth marketplace guide](/guides/oauth-marketplace) before
    implementing this walkthrough.
  </Tab>

  <Tab title="Development">
    Start with a `pk_test_...` key. Test writes create isolated ledger rows but
    do not alter live balances, wallet passes, or analytics. Test mode cannot
    create live customers or enrolments, so prepare an existing enrolment first.
  </Tab>
</Tabs>

## The till journey

```mermaid theme={null}
sequenceDiagram
  participant C as Customer wallet
  participant T as Till / POS
  participant P as Perkstar API
  participant B as Partner backend
  C->>T: Present wallet barcode
  T->>B: Raw scanned value + basket reference
  B->>P: POST /marketplace/accrue
  P->>P: Resolve customer, card, enrolment
  P->>P: Deduplicate external transaction
  P-->>B: Customer + enrolment + transaction
  B-->>T: Loyalty applied / reward state
  P-->>C: Wallet pass refresh
  Note over B,P: Webhooks + scheduled reconciliation keep both systems aligned
```

## Step 1: confirm the installation

```bash theme={null}
curl "$PERKSTAR_API_URL/marketplace/ping" \
  --header "Authorization: Bearer $PERKSTAR_API_KEY"
```

Persist the returned organisation ID, currency, timezone, key mode, and card
choices. If `default_card_id` is `null`, the business has more than one active
card. Ask the operator to map each till programme or location to a specific
`card_id`; never select the first result silently.

<Warning>
  A credential belongs to one Perkstar organisation. If the response names a
  different business from the one your installation expects, stop setup rather
  than writing to it.
</Warning>

## Step 2: map stores and programmes

Use `GET /locations` with `LOCATIONS_READ` to resolve Perkstar location IDs.
Store a mapping instead of comparing display names.

| POS concept | Perkstar value | Rule |
| - | - | - |
| Merchant/account | Organisation ID from `marketplace/ping` | Immutable installation owner |
| Loyalty programme | `card_id` | Explicit when several cards are active |
| Store/site | `location_id` | Map once, refresh when configuration changes |
| Order/payment | `external_transaction_id` | Stable and permanently unique for the business |
| Request attempt | `Idempotency-Key` | Reuse for every retry of the same operation |

## Step 3: identify the customer

Perkstar resolves identifiers in a fixed priority. Send only identifiers that
belong to the same person.

<Tabs>
  <Tab title="Perkstar Customer ID">
    ```json theme={null}
    {
      "identifier": {
        "customer_id": "cus_123"
      }
    }
    ```

    Map this opaque ID from an earlier Perkstar API or Zapier step. It is
    scoped to the connected business. An unknown ID returns `404` and never
    creates a replacement customer from the ID alone.
  </Tab>

  <Tab title="Wallet scan">
    ```json theme={null}
    {
      "identifier": {
        "card_serial": "exact-value-returned-by-the-scanner"
      }
    }
    ```

    Pass the scanned content unchanged. Current opaque POS identifiers and
    older numeric/serial formats are supported. Do not parse, group, truncate,
    convert case, or assume a fixed length.
  </Tab>

  <Tab title="POS customer ID">
    ```json theme={null}
    {
      "identifier": {
        "external_ref": "odoo:company_4:partner_981"
      }
    }
    ```

    Namespace stable IDs with the provider and account so two merchants cannot
    accidentally generate the same local reference.
  </Tab>

  <Tab title="Email or phone">
    ```json theme={null}
    {
      "identifier": {
        "email": "alice@example.com"
      }
    }
    ```

    Use contact details only when the POS has collected them legitimately. An
    email match is case-insensitive; phone matching prefers normalised E.164
    and retains the stored raw value as a compatibility fallback.
  </Tab>
</Tabs>

Resolution order is `customer_id`, `card_serial`, first-party provider
identity, `external_ref`, email, then phone. The first match wins.

## Step 4: add loyalty

```bash theme={null}
curl --request POST "$PERKSTAR_API_URL/marketplace/accrue" \
  --header "Authorization: Bearer $PERKSTAR_API_KEY" \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: odoo:company_4:order_94721:loyalty" \
  --data '{
    "identifier": { "card_serial": "value-from-scanner" },
    "card_id": "card_123",
    "transaction": {
      "type": "STAMP",
      "delta": 1,
      "external_transaction_id": "odoo:company_4:order_94721",
      "metadata": { "locationId": "location_123" }
    }
  }'
```

<Tabs>
  <Tab title="Stamp card">
    Send `type: "STAMP"` and the number of stamps in `delta`. Most integrations
    send `1` after an eligible completed sale or visit.
  </Tab>

  <Tab title="Points from spend">
    Send the qualifying amount in `amount_pence`. Perkstar applies the card's
    current points rule. Send an explicit `delta` only when the upstream system
    is contractually authoritative for the points amount.
  </Tab>

  <Tab title="Manual adjustment">
    Use `ADJUST` only for an intentional correction with a useful note. A refund
    should use reversal so the audit trail remains linked.
  </Tab>
</Tabs>

Two safety keys protect the operation:

* `Idempotency-Key` replays the same HTTP result during the request-cache
  window; and
* `external_transaction_id` permanently prevents the same POS sale from
  earning twice.

Use both and choose a namespace owned by your integration, such as
`odoo:<company>:order:<id>`. Provider-managed prefixes such as `sq_payment_`
are reserved and return `400 reserved_external_transaction_id`. A timeout does
not prove the write failed.

## Step 5: update the till

Use the returned customer, enrolment, balance, and transaction to show a short,
stable result without making another request.

<AccordionGroup>
  <Accordion title="201 Created">
    Loyalty was applied for the first time. Store the returned transaction ID
    beside the POS order/payment reference.
  </Accordion>

  <Accordion title="200 Existing transaction">
    The permanent external reference already existed. Treat this as a safe
    replay, not a second award.
  </Accordion>

  <Accordion title="404 Not found in test mode">
    Test credentials cannot create a live customer or enrolment. Select an
    existing testable customer and card, then retry.
  </Accordion>

  <Accordion title="409 or 422 conflict">
    The supplied identity or external transaction conflicts with an existing
    record or environment. Stop and surface a staff-review state; do not invent
    another identifier.
  </Accordion>

  <Accordion title="429 or 5xx">
    Honour `Retry-After` for `429`. Retry transient failures with backoff and
    the same idempotency and external transaction IDs.
  </Accordion>
</AccordionGroup>

## Step 6: reverse a refund or void

```bash theme={null}
curl --request POST "$PERKSTAR_API_URL/marketplace/reverse" \
  --header "Authorization: Bearer $PERKSTAR_API_KEY" \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: odoo:company_4:refund_301" \
  --data '{
    "external_transaction_id": "odoo:company_4:order_94721",
    "reason": "POS order refunded"
  }'
```

Reversal writes a compensating transaction linked to the original. Replaying a
refund returns the existing reversal. Do not create an unrelated negative
award: it loses the original-sale link and weakens reconciliation.

## Step 7: work through outages

```mermaid theme={null}
flowchart TD
  A["POS operation"] --> B{"Perkstar response received?"}
  B -- Yes --> C{"Accepted or permanent error?"}
  C -- Accepted --> D["Store Perkstar transaction ID"]
  C -- Permanent error --> E["Show staff-review state"]
  B -- No --> F["Queue locally with original IDs and occurred time"]
  F --> G["Retry with backoff and the same IDs"]
  G --> B
```

An offline queue should retain the original merchant, card, location, customer
identifier, transaction reference, idempotency key, amount, and occurrence
time. Encrypt local data, bound retention, and make failed entries visible to
staff.

## Step 8: reconcile

Webhooks provide fast updates; scheduled reads prove eventual consistency.

1. Subscribe to `transaction.created`, `customer.enrolled`,
   `reward.redeemed`, and `tier.changed` as relevant.
2. Verify and persist each event before acknowledging it.
3. At least daily, compare recent POS references with Perkstar transactions.
4. Repair missing writes using the same stable IDs.
5. Alert on a queue that is old, repeatedly failing, or growing unexpectedly.

See the [webhook catalogue](/reference/webhook-events) for exact payloads.

## Go-live proof

* [ ] Installation identifies the expected organisation.
* [ ] Every store and loyalty programme is mapped explicitly.
* [ ] Old and current wallet barcode formats resolve unchanged.
* [ ] Replaying a sale never awards twice.
* [ ] A timeout followed by retry converges on one transaction.
* [ ] Full refund creates one linked reversal.
* [ ] Test traffic cannot change a live customer balance.
* [ ] Offline work survives restart and has a visible failure state.
* [ ] Webhook signatures and event deduplication are tested.
* [ ] Reconciliation finds and repairs a deliberately skipped event.


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