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_STATUS_CHANGEDdelivery.
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 — 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 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>: 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.