Generic connector
The generic connector is the universal ingestion path: it works with any POS or e-commerce system on day one, with no per-provider adapter to build. It’s also the platform’s reference adapter — its wire payload is the canonical event shape every first-party connector (Square, Clover, Lightspeed, LithosPOS, …) maps into internally, so anything you learn here carries over to reading webhook payloads from those connectors too.
Endpoint
POST /v1/ingest/generic
Auth: apiKey with transactions:write. Unlike POST /v1/transactions, this endpoint takes
no Idempotency-Key header — see Delivery dedup below
for why, and it answers 202 Accepted immediately: the POS must never block on rules
evaluation, member matching, or ledger writes.
Payload shape
{
"delivery_id": "fix-sale-2",
"events": [
{
"type": "sale",
"txn": {
"external_id": "fix-sale-2-t1",
"occurred_at": "2026-07-05T11:00:00Z",
"currency": "USD",
"gross": 5000,
"net": 5000,
"line_items": [{ "sku": "COFFEE", "qty": 2 }]
},
"identifiers": [
{ "type": "phone", "value": "+15550002222" },
{ "type": "loyalty_number", "value": "123456" }
]
}
]
}
(This is one of the repo’s own contract-test fixtures,
packages/pos-gateway/fixtures/generic/sale-with-identifiers.json — not a made-up example.)
| Field | Notes |
|---|---|
delivery_id |
1–200 chars. Your own idempotency key for this whole batch — see below. |
events |
1–100 CanonicalEvent objects per request. |
events[].type |
"sale" | "refund". |
events[].txn.external_id |
Your system’s transaction id — this, plus source_system (fixed to "generic" on this path), is the ledger’s own idempotency pair. |
events[].txn.occurred_at |
ISO 8601 datetime, local offsets accepted (+05:30, not just Z) — POS providers routinely emit local-offset timestamps. |
events[].txn.currency |
ISO 4217, 3 letters. |
events[].txn.gross / .net |
Integer minor units (cents), non-negative. |
events[].txn.location_external_ref |
Optional — your own location id/code. |
events[].txn.line_items |
Optional, free-form objects. |
events[].txn.tender |
Optional, free-form (cash/card split, etc). |
events[].txn.refund_of |
Required when type is "refund" — the original sale’s external_id. Omitting it on a refund is a validation error. |
events[].identifiers |
Array of {type, value} — type is one of phone | email | qr | loyalty_number | card_token | external. Can be empty. |
Member matching
identifiers is tried against the same ordered matching chain every ingestion path uses: phone
(exact E.164) → loyalty number → QR token → card token → email — first hit wins. An event with no
match (or an empty identifiers array) is not rejected: it’s stored as an anonymous canonical
transaction (member_id: null, zero points earned) rather than failing the batch, so it can still
feed analytics or a later enrolled-backfill pass.
Refunds — canonical record only, for now
A "refund" event is matched and recorded as a canonical transaction linked via refund_of, but
this ingestion path does not yet write a ledger REVERSAL entry automatically — that
reconciliation is a later pass, not this one. Don’t rely on a generic-ingested refund to move a
member’s balance by itself yet.
One active program per tenant (today)
Unlike POST /v1/transactions, generic-ingest events carry no program_id — queued processing
resolves it by requiring exactly one active program for the tenant. A tenant running more than
one active program (or none) will see events land in FAILED state with a message to that effect.
If you need multi-program routing today, send through POST /v1/transactions (or /v1/till/sale)
with an explicit program_id instead.
Delivery dedup, not Idempotency-Key
Every other mutating /v1 route dedupes on a client-supplied Idempotency-Key header. Generic
ingest dedupes differently, on delivery_id (scoped per tenant internally): redelivering the
same delivery_id — a queue-at-least-once retry, a connector replaying a webhook it isn’t sure
landed, whatever — never reprocesses the batch. The existing event’s id and current state come
back unchanged, still as 202:
{ "event_id": "01J...", "state": "RECEIVED" }
There’s deliberately no Idempotency-Key header on this route — delivery_id already is the
idempotency key, and it’s yours to choose (your own webhook/message id is the natural choice).
Response states
{ "event_id": "01J...", "state": "RECEIVED" }
state is one of:
| State | Meaning |
|---|---|
RECEIVED |
Accepted and persisted; not yet processed (typical response when processing is queued rather than inline). |
MAPPED |
Payload validated against the canonical shape. Transient — you’ll rarely observe it at rest. |
PROCESSED |
Member matching, rules evaluation, and ledger writes completed. |
FAILED |
Validation failed (never reached MAPPED), or something after mapping threw (never reached PROCESSED) — e.g. the no-active-program case above. |
Redelivering a delivery_id that was already accepted is a safe no-op, not an error: the response
echoes the original event’s stored state (typically PROCESSED), and nothing is booked twice.
The happy path is RECEIVED → MAPPED → PROCESSED. A payload that fails canonical validation goes
straight RECEIVED → FAILED (it never reaches MAPPED). This endpoint’s 202 response reflects
whatever state is known at response time — under queued processing that’s almost always
RECEIVED; poll or listen for transaction.recorded (see the
webhook verification guide) to observe the final state.
Local dev: INGEST_INLINE=1
In local development (no queue binding wired up), set INGEST_INLINE=1 and edge-api runs the
whole pipeline inline, in the same request — the 202 response’s state will already be the
final one (PROCESSED/FAILED) instead of RECEIVED. In a real deployment this is
unset and processing happens on a queue consumer instead; don’t depend on synchronous processing
in production code.
Till sync — the alternative for counter-facing flows
Generic ingest is deliberately fire-and-forget: good for a connector or webhook relay that doesn’t
need the result back on this request. When you do need an immediate answer — a till/cashier
screen showing the customer’s balance or redeeming a reward on the spot — use the till
endpoints instead, which are synchronous and (unlike ingest) do require Idempotency-Key:
| Endpoint | Use |
|---|---|
POST /v1/till/lookup |
Resolve an identifier to a member + balance + redeemable rewards, right now. |
POST /v1/till/sale |
Record a sale synchronously — identical earning logic to ingest, but you get {earned, balance} back immediately. |
POST /v1/till/redeem |
Quote and commit a redemption in one call — the cashier already confirmed verbally, so there’s no separate confirm step. |
Both take the same identifier-XOR-member_id shape as member on POST /v1/transactions. See
the quickstart for the request/response shapes those return.
Full example
curl -sS http://localhost:8787/v1/ingest/generic \
-H "x-api-key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"delivery_id": "batch-2026-07-06-0001",
"events": [
{
"type": "sale",
"txn": {
"external_id": "pos-order-9931",
"occurred_at": "2026-07-06T09:15:00-05:00",
"currency": "USD",
"gross": 2450,
"net": 2450,
"line_items": [{ "sku": "LATTE", "qty": 1 }, { "sku": "CROISSANT", "qty": 1 }]
},
"identifiers": [{ "type": "phone", "value": "+15550002222" }]
},
{
"type": "refund",
"txn": {
"external_id": "pos-refund-118",
"occurred_at": "2026-07-06T09:20:00-05:00",
"currency": "USD",
"gross": 500,
"net": 500,
"refund_of": "pos-order-9931"
},
"identifiers": [{ "type": "phone", "value": "+15550002222" }]
}
]
}' | jq .
Next steps
- Webhook verification — verify the outbound events (e.g.
transaction.recorded) this platform sends once your event reachesPROCESSED. - API reference — the full
GenericIngestRequest/CanonicalEventschemas.