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