LithosPOS connector

The LithosPOS integration is a hybrid of two channels, both built on this platform’s public surface — there are no private interfaces:

Channel Direction Used for
Till API (POST /v1/till/lookup / sale / redeem) synchronous, at the counter member lookup, member-identified sales, redemptions — anything the cashier needs an immediate answer for
Signed webhooks (POST /webhooks/lithospos, this page) asynchronous, fire-and-forget refunds, voids, guest sales, and every sale as recovery/backfill — till-booked sales dedupe safely

This page is the partner contract for the webhook channel: the wire format below is defined and published by this platform, and the LithosPOS-side sender is implemented against it. The till API flow is summarized at the end with pointers to its own docs.

1. Provision a connection

POST /v1/pos-connections

Auth: dashboard session or apiKey with connectors:manage.

{ "provider": "lithospos" }

The response is the connection’s usual masked shape, plus the one-time secret:

{
  "id": "01J6Z3R7F4K5J9YV2S9SPX7QDH",
  "provider": "lithospos",
  "status": "active",
  "provider_account_ref": "lp_conn_01J6Z3R8Q6TQXW2K6V9F1YB7ME",
  "location_map": {},
  "last_sync_at": null,
  "created_at": "2026-07-06T12:00:00.000Z",
  "secret": "lpwh_72d1fe26a66456d68251…"
}

Two fields the sender must store, out of that response:

The same /v1/pos-connections routes list connections, patch the connection’s location_map, and disable a connection — see the API reference for the exact schemas.

2. Wire contract

This is the contract shape, verbatim from the platform’s design spec:

POST https://<edge>/webhooks/lithospos
Headers: X-Loyalty-Signature: t=<unix>,v1=<hex hmac-sha256(secret, `${t}.${rawBody}`)>   // same scheme as our outbound
{
  "connection_ref": "lp_conn_<ulid>",       // issued at connection creation; routing key
  "delivery_id": "store42-20260706-000123", // sender-unique; retries reuse it
  "events": [{
    "type": "sale" | "refund",
    "txn": {
      "external_id": "INV-42-7-000123",     // composite invoice ref — sender's stable id
      "occurred_at": "2026-07-06T13:00:00+04:00",  // till time; batch-sync lag tolerated
      "currency": "AED",                     // REQUIRED — LithosPOS sale rows lack currency; sender derives from store config
      "gross": 5000, "net": 5000,           // integer minor units
      "location_external_ref": "store-42",
      "line_items": [...], "tender": {...},
      "refund_of": "INV-..."                 // refunds/voids both map to type=refund
    },
    "identifiers": [ {"type":"phone","value":"+9715..."}, {"type":"loyalty_number","value":"..."} ]  // empty for guest sales
  }]
}

The events array reuses the platform’s canonical event shape — the same one the generic connector ingests — with one addition on top: connection_ref. (The repo’s own contract-test fixtures for this adapter, packages/pos-gateway/fixtures/lithospos/*.json, are real examples of this exact shape.)

Field rules

Field Rules
connection_ref lp_conn_ + 26-char ULID (case-insensitive), from connection provisioning. A well-formed body missing this field is rejected 400; an unknown value is acknowledged 200 and dropped (see delivery semantics).
delivery_id 1–200 chars, unique per connection, chosen by the sender. A retry of the same delivery MUST reuse the same delivery_id — that is what makes retries safe. store + date + counter (as above) is a good scheme.
events 1–100 events per delivery.
events[].type "sale" | "refund". Voids are sent as "refund" for the full amount — the platform makes no distinction.
txn.external_id The sender’s stable transaction id — a composite invoice ref (INV-<store>-<register>-<counter> style) that never changes across retries or re-syncs. This plus the fixed source system "lithospos" is the ledger’s own idempotency pair, independent of delivery_id.
txn.occurred_at ISO 8601, local offsets accepted (+04:00). This is the till time — when the sale actually happened, not when the webhook was sent. Batch-sync lag is expected and tolerated: a delivery arriving hours later, carrying an old occurred_at, is fine (freshness is enforced on the signature timestamp instead, see below).
txn.currency ISO 4217, 3 letters, required on every event. LithosPOS sale rows don’t carry a currency — the sender derives it from the store’s configuration and stamps it on every event.
txn.gross / txn.net Integer minor units (fils/cents), non-negative. Never floats.
txn.location_external_ref Optional — the sender’s own store/till code. Resolved through the connection’s location_map (see location mapping).
txn.line_items / txn.tender Optional, free-form JSON (array of objects / object).
txn.refund_of Required when type is "refund" — the original sale’s external_id. Omitting it fails validation.
identifiers {type, value} pairs; typephone | email | qr | loyalty_number | card_token | external. Phone values MUST be E.164 (+9715…) — non-E.164 phones are skipped, not guessed at. Empty array = guest sale. Matching follows the platform-wide ordered chain (phone → loyalty number → QR → card token → email, first hit wins — see the generic connector guide).

3. Signing deliveries

Every delivery carries an X-Loyalty-Signature header using the platform’s one signing scheme — the exact same t=,v1= HMAC recipe this platform uses for the webhooks it sends to merchants, so one implementation covers both directions. The full recipe, tolerance rules, and a verify-side sample live in the webhook verification guide; the sender side is:

X-Loyalty-Signature: t=<unix seconds>,v1=<hex HMAC-SHA-256(secret, `${t}.${rawBody}`)>
// Sender side (plain Node, no dependencies) — the mirror image of the verify sample in the
// webhook verification guide.
const crypto = require("node:crypto");

function signLithosDelivery(secret, rawBody) {
  const t = Math.floor(Date.now() / 1000);
  const v1 = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return `t=${t},v1=${v1}`;
}

// rawBody MUST be the exact bytes you put on the wire — serialize once, sign that string,
// send that string. Re-serializing between signing and sending is the classic way to break this.
const rawBody = JSON.stringify(payload);
const headers = {
  "Content-Type": "application/json",
  "X-Loyalty-Signature": signLithosDelivery(connectionSecret, rawBody),
};

Two rules that follow from the recipe:

4. Delivery semantics

Responses

Status Body Meaning Sender action
200 {"ok":true} Accepted — or an idempotent duplicate, or an unknown connection_ref (deliberately indistinguishable). Done. Do not retry.
400 error envelope, VALIDATION_FAILED Unroutable: body isn’t JSON, or connection_ref/delivery_id missing/non-string. Nothing was persisted. Fix the sender — retrying the same bytes can never succeed.
403 error envelope, FORBIDDEN Signature verification failed. The delivery is persisted as a failed audit record but never processed. Check the secret, the raw-bytes rule, and clock skew; then retry (a later valid retry with the same delivery_id is not blocked by the audit record).
5xx Transient platform fault. Retry with backoff, same delivery_id, fresh signature.

At-least-once, dedupe, and idempotency

Deliveries are at-least-once from the sender’s point of view: send, and if anything but a 200/400 comes back (or nothing comes back), retry with backoff — same delivery_id, same body, fresh signature. Deduplication is layered so none of this can double-book:

  1. Delivery level — the platform dedupes on connection_ref:delivery_id. A redelivered delivery_id is acknowledged 200 and never reprocessed (if the first attempt’s processing was interrupted mid-flight, the redelivery re-drives it — safely, because of layer 2).
  2. Transaction level — every ledger and transaction write is idempotent on ("lithospos", txn.external_id) regardless of which delivery carried it. Even a sender bug that re-sends an old transaction under a brand-new delivery_id cannot earn points twice.

Processing is asynchronous

A 200 means the delivery was accepted and durably queued, not that points were awarded — validation of the event payload itself and all ledger effects happen on a queue consumer moments later. A delivery that passes routing + signature but carries an invalid payload (e.g. a missing currency) is acknowledged 200 and then parks as failed during processing; it is visible to the merchant’s platform operators, not signaled back on the HTTP response. To observe outcomes, merchants subscribe to the platform’s outbound webhooks: every earned sale emits transaction.recorded and every points reversal emits transaction.reversed (see webhook verification).

A delivery is atomic: the whole payload is schema-validated before any event is processed, and all events in one delivery are booked in a single transaction — a failed delivery books nothing. Note that queued processing currently requires the tenant to have exactly one active program, same as generic ingest (see the generic connector guide).

Location mapping

txn.location_external_ref is the sender’s own store/till code. Merchants map those codes to platform locations on the connection (PATCH /v1/pos-connections/{id} with {"location_map": {"store-42": "<location_id>"}}). An unmapped code never fails a sale — the transaction books without a location and the platform logs the unmapped ref so the merchant can add it to the map.

5. Guest sales

A sale with an empty identifiers array is valid and expected — walk-in customers without a loyalty identity are the majority of transactions. Guest sales are stored as anonymous canonical transactions (no member, zero points) rather than rejected, so they still feed analytics and a later enrolled-backfill pass. Send them; don’t filter them out sender-side.

6. Refunds and voids

Both map to type: "refund" — a void is simply a refund for the full amount. txn.refund_of carries the original sale’s external_id, and the platform reverses points proportionally: a refund of 40% of the original net claws back floor(40%) of the points that sale earned, cumulative partial refunds never reverse more than was originally earned, and a refund of a guest (anonymous) sale records the refund without any points effect.

One boundary to know: automatic points reversal now covers originals recorded through either channel. A refund whose refund_of points at a sale that was earned synchronously through the till API (source_system: "till") reverses points against that till-booked EARN the same way a refund of a webhook-ingested sale does — when the platform can’t find the original under this webhook channel’s own namespace, it automatically falls back to the till namespace before giving up. There’s nothing to configure: the fallback is automatic for this connector, and a refund whose original truly isn’t on record under either namespace (pre-integration sale, or the original simply never arrived) still stores the refund without a points effect, same as before.

The till API at checkout

Checkout-time features deliberately do not ride this webhook channel — the connector advertises no at-POS capabilities. When the cashier needs an immediate answer, the LithosPOS till calls the synchronous till endpoints, authenticated with an ordinary platform API key (x-api-key, minted per the quickstart with members:read, transactions:write, and redemptions:* permissions):

Step Endpoint Purpose
lookup POST /v1/till/lookup Resolve a phone/QR/loyalty number to member + balance + redeemable rewards, on screen, right now.
sale POST /v1/till/sale Record a member-identified sale synchronously — {earned, balance} comes back for the receipt/screen.
redeem POST /v1/till/redeem Quote + commit a redemption in one call, cashier-confirmed.

Request/response shapes are in the generic connector guide’s till section and the API reference.

Re-carrying a till-booked sale over the webhook channel is safe. The till API and this webhook channel are separate idempotency namespaces (source systems "till" and "lithospos" respectively), but the platform dedupes across them for this connector: a sale already recorded synchronously via POST /v1/till/sale that arrives again as a webhook sale event with the same external_id is recognized as the same transaction and processed as a no-op replay — zero additional points, no duplicate transaction record, no outbound webhook — rather than earning twice. Recovery is free as a result: a sweep/reconciliation process can re-send every sale it knows about, member-identified ones included, without tracking which ones already went through the till API. The division of labor below is a recommendation for where each sale gets its fastest path, not a hard requirement:

Next steps