Skip to main content
This page documents the requests Stell sends to a webhook endpoint, for the developer building the receiver. For creating and managing subscriptions in the portal, see event webhooks.

The delivery request

Each delivery is an HTTP POST to the subscription’s URL with a JSON body:
Any custom headers configured on the subscription — for example Authorization: Bearer <your token> — are sent verbatim on every delivery. Respond with any 2xx status code (200, 202, …) to acknowledge the delivery. Only the status code matters; the response body is ignored.

Event payloads

Payload conventions across all events:
  • Timestamps are ISO 8601 strings in UTC, for example 2026-05-31T14:32:00.000Z.
  • Identifiers are opaque strings — don’t parse meaning out of their format.
  • Optional fields with no value are omitted entirely, never sent as null. Check for presence, not for null.
  • Payloads carry identifiers, not personal data. To link a delivery to a customer in your own system, set an External ID when the pass is issued — it’s echoed back in every PASS_STATUS_CHANGED delivery.

Pass status changed

PASS_STATUS_CHANGED fires each time a pass moves between lifecycle states: PREACTIVE, ACTIVE, INACTIVE, VOIDED, REVOKED, EXPIRED. The payload carries both the new and the previous status, so you can detect specific transitions without tracking prior state yourself.
The enrollment moment — a customer adding their pass to Apple Wallet or Google Wallet — is the transition status: "ACTIVE" with previousStatus: "PREACTIVE". Filter on that pair if you only care about enrollments.
Example

Transaction created

TRANSACTION_CREATED fires when a new transaction is recorded against a pass — most commonly a customer tapping their pass at a terminal. It’s delivered once per transaction.
Example
In this example, optional fields with no value — such as statusReason and pointsSpent — are simply absent.

Retries and idempotency

Deliveries are at-least-once: your endpoint receives every event at least once, and may occasionally receive the same event twice — for example when a retry lands after your endpoint already processed the original but the response was lost. Processing the same delivery twice must not produce a duplicate side effect. A delivery succeeds when your endpoint responds with a 2xx status code. Anything else — a non-2xx status, a connection or TLS error, a timeout — triggers a retry with short backoff, for up to 3 attempts in total: After the third failed attempt, the delivery is dropped — deliveries are not queued indefinitely, so monitor your endpoint for sustained failures.

Deduplicate on the delivery ID

The same X-Stell-Delivery-Id identifies the same logical delivery across retries:
  1. Read X-Stell-Delivery-Id from the incoming request.
  2. If you’ve already processed that ID, acknowledge with 2xx and do nothing else.
  3. Otherwise process the event, record the ID as processed, then acknowledge with 2xx.
Record the ID and the side effect atomically — or the ID first — so a crash between processing and acknowledging can’t cause a duplicate on the next retry. Keeping processed IDs for a few hours is ample given the retry schedule.

Endpoint recommendations

  • Acknowledge fast, process asynchronously. Respond 2xx as soon as the delivery is safely recorded — for example, queued for background processing — and do the heavy work out of band.
  • Verify the signature first, before doing any work, when you use a signing secret.
  • Tolerate unknown event types. New types are added over time; if X-Stell-Event is something you don’t handle, acknowledge with 2xx and ignore it.
  • Don’t rely on ordering. Deliveries aren’t guaranteed to arrive in the order events occurred — for pass status changes, reconcile using status, previousStatus, and occurredAt rather than arrival order.

Verify the signature

When the subscription has a signing secret, every delivery is signed so you can verify it genuinely came from Stell and wasn’t tampered with in transit. Without a secret, deliveries are sent unsigned — configuring one is strongly recommended for production endpoints. The X-Stell-Signature header uses the widely adopted Stripe-style format, so existing verification snippets need minimal changes:
  • t — the Unix timestamp, in seconds, when Stell signed the payload.
  • v1 — a lowercase hex-encoded HMAC-SHA256 over the string <t>.<rawBody>: the timestamp, a literal period, then the exact raw request body bytes. The HMAC key is the subscription’s signing secret, used as a UTF-8 string (including its whsec_ prefix).
To verify:
  1. Read the raw request body before any JSON parsing — re-serializing parsed JSON changes the bytes and breaks verification.
  2. Parse the header into t and v1.
  3. Compute HMAC-SHA256(signingSecret, "<t>.<rawBody>") and hex-encode it.
  4. Compare with v1 using a constant-time comparison.
  5. Reject deliveries whose timestamp is too old or in the future — a tolerance of about 5 minutes is a good default and defends against replays.
The signature covers the raw body bytes. In Express, use express.raw({ type: 'application/json' }) (or read the raw stream) for the webhook route — parsing the body as JSON and JSON.stringify-ing it back changes the bytes and verification fails.
Common pitfalls:
  • Verifying parsed JSON instead of the raw body — capture the body before any JSON middleware touches it.
  • Plain == comparison — use crypto.timingSafeEqual or hmac.compare_digest to avoid leaking signature bytes through timing.
  • Skipping the timestamp check — without it, a captured delivery can be replayed indefinitely.
  • Treating t as milliseconds — it’s Unix time in seconds.