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:
id— this connection’s own internal id. Use this field (notprovider_account_ref) in every laterGET/PATCH/DELETE /v1/pos-connections/{id}call.provider_account_ref(lp_conn_+ 26-char ULID) — the routing key: this is the exact value the wire contract’sconnection_reffield must carry in every webhook delivery’s body (below), and is how a delivery is matched to its merchant before anything else happens. Same value, two names depending which side of the API you’re reading: the provisioning response above calls itprovider_account_ref(the generic per-provider routing column every connection type uses, square included); the inbound webhook payload calls the identical valueconnection_ref. It is not a secret — treat it like an account id.secret(lpwh_+ 48 hex chars) — the per-connection webhook signing key. Shown in full exactly once, in this response, same rule as webhook-endpoint secrets: it is stored encrypted at rest and every laterGET /v1/pos-connectionsreturns only masked connection fields, never the secret again.
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; type ∈ phone | 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:
tis send time, not sale time. The platform rejects signatures whose timestamp is more than 300 seconds from its own clock (in either direction — replay protection). The business time of the transaction rides inoccurred_atand can lag arbitrarily; the signature timestamp cannot. Keep the sender’s clock NTP-sane.- Retries re-sign. A retry minutes later reuses the same
delivery_idand the same body, but must compute a fresh header (newt, newv1) — replaying the original header verbatim will be outside tolerance and rejected403.
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:
- Delivery level — the platform dedupes on
connection_ref:delivery_id. A redelivereddelivery_idis acknowledged200and never reprocessed (if the first attempt’s processing was interrupted mid-flight, the redelivery re-drives it — safely, because of layer 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-newdelivery_idcannot 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:
- Till API: sales where the member was identified at the counter (and redemptions, and
lookups) — this is what gives the till screen its immediate
{earned, balance}response. - Webhook channel: everything else — guest sales, refunds, voids, and recovery/backfill of every sale (till-booked ones included) for durability.
Next steps
- Webhook verification — the shared
t=,v1=signature scheme in full, from the receiving side. - Generic connector — the canonical event shape this contract embeds, member matching, and processing states.
- Quickstart — account, organization, and API-key provisioning for the till API.
- API reference —
LithosIngestRequest,CanonicalEvent, and the/v1/pos-connectionsschemas.