The delivery request
Each delivery is an HTTPPOST 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_CREATEDandPASS_STATUS_CHANGEDdelivery.
Pass created
PASS_CREATED fires once, when a pass is issued, whatever issued it: the portal, an enrollment page, or the API. It arrives before the customer has added the pass to a wallet, so status is normally PREACTIVE.
The payload mirrors Pass status changed without previousStatus, since a pass being issued has no prior lifecycle state.
Example
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.
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
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, whether a non-2xx status, a connection or TLS error, or a timeout, triggers a retry with short backoff, for up to 3 attempts in total:
After the third failed attempt, the delivery cycle fails, but the event is not gone. Failed events are re-driven from Stell’s internal queue, so during a sustained outage your endpoint sees repeated delivery cycles for up to roughly 24 hours before the event ages out. The delivery ID stays the same across all of them, which is why deduplication (below) is essential. An endpoint that was down for an hour will receive well over three POSTs for the same event once it recovers.
Deduplicate on the delivery ID
The sameX-Stell-Delivery-Id identifies the same logical delivery across retries:
- Read
X-Stell-Delivery-Idfrom the incoming request. - If you’ve already processed that ID, acknowledge with 2xx and do nothing else.
- Otherwise process the event, record the ID as processed, then acknowledge with 2xx.
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-Eventis 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, andoccurredAtrather 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. TheX-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>, meaning 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 itswhsec_prefix).
- Read the raw request body before any JSON parsing. Re-serializing parsed JSON changes the bytes and breaks verification.
- Parse the header into
tandv1. - Compute
HMAC-SHA256(signingSecret, "<t>.<rawBody>")and hex-encode it. - Compare with
v1using a constant-time comparison. - 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.
- Verifying parsed JSON instead of the raw body. Capture the body before any JSON middleware touches it.
- Plain
==comparison. Usecrypto.timingSafeEqualorhmac.compare_digestto avoid leaking signature bytes through timing. - Skipping the timestamp check. Without it, a captured delivery can be replayed indefinitely.
- Treating
tas milliseconds. It’s Unix time in seconds.