> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getstell.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook deliveries

> The requests Stell sends to your webhook endpoint — headers, payloads, retries, and signature verification

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](/programs/webhooks).

## The delivery request

Each delivery is an HTTP `POST` to the subscription's URL with a JSON body:

```http theme={null}
POST /webhooks/stell HTTP/1.1
Content-Type: application/json
User-Agent: Stell-Webhooks/1.0
X-Stell-Event: PASS_STATUS_CHANGED
X-Stell-Delivery-Id: 9d4c1e0a...74b25f
X-Stell-Signature: t=1717160000,v1=8f2a4c...e1b9
```

| Header                | Purpose                                                                                                                       |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `X-Stell-Event`       | The event type of this delivery. Use it to route to the right handler.                                                        |
| `X-Stell-Delivery-Id` | A unique identifier for the logical delivery, stable across retries. **Use this as your idempotency key.**                    |
| `X-Stell-Signature`   | The payload signature, present when the subscription has a signing secret. See [verify the signature](#verify-the-signature). |

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](/passes/lifecycle): `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.

<Tip>
  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.
</Tip>

| Field            | Type              | Optional | Description                                                                     |
| ---------------- | ----------------- | -------- | ------------------------------------------------------------------------------- |
| `id`             | string            | No       | The pass whose status changed — the same ID the pass was created with.          |
| `companyId`      | string            | No       | Your account identifier.                                                        |
| `programId`      | string            | Yes      | The program the pass belongs to.                                                |
| `externalId`     | string            | Yes      | Your external identifier for the pass, if one was set when the pass was issued. |
| `status`         | string            | No       | The new status.                                                                 |
| `previousStatus` | string            | Yes      | The status before this change. Absent if there was no prior status.             |
| `walletType`     | string            | Yes      | The wallet platform: `APPLE_WALLET`, `GOOGLE_WALLET`, or `UNKNOWN`.             |
| `occurredAt`     | string (ISO 8601) | No       | When the status change occurred.                                                |

```json Example theme={null}
{
  "id": "pass_ghi789",
  "companyId": "company_abc123",
  "programId": "program_def456",
  "externalId": "member_001",
  "status": "ACTIVE",
  "previousStatus": "PREACTIVE",
  "walletType": "APPLE_WALLET",
  "occurredAt": "2026-05-31T14:35:12.000Z"
}
```

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

| Field                 | Type              | Optional | Description                                                                                                             |
| --------------------- | ----------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `id`                  | string            | No       | Unique identifier of the transaction.                                                                                   |
| `companyId`           | string            | No       | Your account identifier.                                                                                                |
| `passId`              | string            | No       | The pass the transaction applies to.                                                                                    |
| `storeId`             | string            | No       | The store where the transaction occurred.                                                                               |
| `programId`           | string            | Yes      | The program the pass belongs to.                                                                                        |
| `terminalId`          | string            | Yes      | The terminal that produced the transaction, if applicable.                                                              |
| `type`                | string            | Yes      | Transaction type. Currently `ENTRY_CHECK_IN` for entry taps.                                                            |
| `instrument`          | string            | Yes      | How the transaction was captured: `NFC_TAP`, `BARCODE_SCAN`, `ONLINE`, or `MANUAL_ENTRY`.                               |
| `status`              | string            | Yes      | Outcome: `COMPLETED`, `FAILED`, `PENDING`. Defaults to `PENDING` when not otherwise set.                                |
| `statusReason`        | string            | Yes      | Human-readable reason accompanying the status, when available.                                                          |
| `reference`           | string            | Yes      | Your or the terminal's reference for the transaction.                                                                   |
| `transactionDate`     | string (ISO 8601) | Yes      | When the transaction took place, if reported separately from `createdAt`.                                               |
| `pointsEarned`        | number            | Yes      | Loyalty points earned in this transaction.                                                                              |
| `pointsSpent`         | number            | Yes      | Loyalty points spent in this transaction.                                                                               |
| `remainingUsesBefore` | number            | Yes      | Remaining entry/access uses before this transaction (access-control passes).                                            |
| `remainingUsesAfter`  | number            | Yes      | Remaining entry/access uses after this transaction (access-control passes).                                             |
| `payment`             | object            | Yes      | Payment details, when the transaction involved a payment: `amount` (with `value` and ISO 4217 `currency`) and `method`. |
| `additionalData`      | object            | Yes      | Free-form key/value data attached to the transaction.                                                                   |
| `createdAt`           | string (ISO 8601) | No       | When the transaction record was created.                                                                                |
| `updatedAt`           | string (ISO 8601) | No       | When the transaction record was last updated.                                                                           |

```json Example theme={null}
{
  "id": "txn_8f2a1c9e",
  "companyId": "company_abc123",
  "programId": "program_def456",
  "passId": "pass_ghi789",
  "storeId": "store_jkl012",
  "terminalId": "terminal_mno345",
  "reference": "POS-2026-0531-0042",
  "transactionDate": "2026-05-31T14:31:58.000Z",
  "type": "ENTRY_CHECK_IN",
  "instrument": "NFC_TAP",
  "status": "COMPLETED",
  "pointsEarned": 10,
  "remainingUsesAfter": 4,
  "payment": {
    "amount": { "value": 49.9, "currency": "NOK" },
    "method": "CARD"
  },
  "additionalData": { "lane": "north-entrance" },
  "createdAt": "2026-05-31T14:32:00.000Z",
  "updatedAt": "2026-05-31T14:32:00.000Z"
}
```

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:

| Attempt | Delay before sending |
| ------- | -------------------- |
| 1       | Immediate            |
| 2       | \~2 seconds          |
| 3       | \~4 seconds          |

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](/programs/webhooks#create-a-subscription), 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:

```
X-Stell-Signature: t=1717160000,v1=8f2a4c...e1b9
```

* `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.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('node:crypto');

  const TOLERANCE_SECONDS = 5 * 60;

  function verifyStellSignature(rawBody, signatureHeader, signingSecret) {
    // Parse "t=<seconds>,v1=<hex>" into its parts.
    const parts = Object.fromEntries(
      signatureHeader.split(',').map((part) => {
        const index = part.indexOf('=');
        return [part.slice(0, index), part.slice(index + 1)];
      })
    );

    const timestamp = Number(parts.t);
    const provided = parts.v1;
    if (!Number.isFinite(timestamp) || typeof provided !== 'string') {
      return false;
    }

    // Reject stale or future-dated deliveries (replay protection).
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
      return false;
    }

    // Recompute the signature over "<t>.<rawBody>".
    const signedContent = `${timestamp}.${rawBody}`;
    const expected = crypto
      .createHmac('sha256', signingSecret)
      .update(signedContent)
      .digest('hex');

    // Constant-time compare. Buffers must be equal length for timingSafeEqual.
    const expectedBuffer = Buffer.from(expected, 'hex');
    const providedBuffer = Buffer.from(provided, 'hex');
    if (expectedBuffer.length !== providedBuffer.length) {
      return false;
    }
    return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  TOLERANCE_SECONDS = 5 * 60


  def verify_stell_signature(raw_body: bytes, signature_header: str, signing_secret: str) -> bool:
      # Parse "t=<seconds>,v1=<hex>" into its parts.
      parts = {}
      for part in signature_header.split(","):
          key, _, value = part.partition("=")
          parts[key] = value

      try:
          timestamp = int(parts["t"])
          provided = parts["v1"]
      except (KeyError, ValueError):
          return False

      # Reject stale or future-dated deliveries (replay protection).
      now = int(time.time())
      if abs(now - timestamp) > TOLERANCE_SECONDS:
          return False

      # Recompute the signature over "<t>.<rawBody>".
      signed_content = f"{timestamp}.".encode("utf-8") + raw_body
      expected = hmac.new(
          signing_secret.encode("utf-8"),
          signed_content,
          hashlib.sha256,
      ).hexdigest()

      # Constant-time compare.
      return hmac.compare_digest(expected, provided)
  ```
</CodeGroup>

<Warning>
  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.
</Warning>

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.
