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. Webhooks push events to you; to read or change state from your side, such as looking up a pass or recording a transaction, use the Stell API.

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_CREATED and PASS_STATUS_CHANGED delivery.

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
Subscribing to both PASS_CREATED and PASS_STATUS_CHANGED gives you the full arc: issued, then added to the wallet. If your own system issued the pass, PASS_CREATED mainly earns its place for passes issued elsewhere.

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, 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 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. Keep processed IDs for at least a day, because failed events can be re-delivered for up to roughly 24 hours.

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>, 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 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.