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

# Webhook delivery

> Verify the signature on an incoming delivery, respond correctly, and keep your subscription healthy.

When an event you've subscribed to occurs, Spirii sends it to your endpoint as a signed JSON POST. Every delivery carries the same envelope and the same three signing headers, whatever the event type.

Subscriptions themselves are managed through the Webhooks endpoints. This page covers what arrives afterwards.

## Your endpoint

Your endpoint has to be reachable over **HTTPS** at a **publicly resolvable address**. Spirii resolves the hostname before each delivery and refuses any that points at a private, loopback, link-local, metadata or otherwise reserved address, on both IPv4 and IPv6. The check runs against the resolved addresses rather than the hostname, so a public name pointing at an internal IP is refused too.

A consequence worth knowing during development: `localhost`, `127.0.0.1` and private-range addresses can never receive deliveries. Use a public tunnel endpoint while you build.

## The delivery request

Each delivery is a `POST` with a JSON body. Spirii waits **10 seconds** for your response.

| Header                | Value                                                                                             |
| --------------------- | ------------------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`                                                                                |
| `X-Signature`         | `sha256=` followed by the hex HMAC of the body. See [Verify the signature](#verify-the-signature) |
| `X-Webhook-Timestamp` | When the delivery was signed, as a Unix timestamp in seconds                                      |
| `X-Webhook-Id`        | The event id, repeated from `event_id` in the body                                                |

Any custom headers you configured on the subscription are sent alongside these. The three signing headers always take precedence, so a custom header cannot overwrite them.

The body is the same envelope for every event type:

| Field          | What it holds                                                     |
| -------------- | ----------------------------------------------------------------- |
| `event_id`     | Unique id for this event. Stable across retries of the same event |
| `type`         | The event type, such as `token.created`                           |
| `api_version`  | The payload version this subscription is pinned to                |
| `occurred_at`  | When the event happened on the platform, ISO 8601                 |
| `published_at` | When Spirii published it for delivery, ISO 8601                   |
| `data`         | The event body, whose shape is defined by the event type          |

```json theme={null}
{
  "event_id": "0199b3c7-4f2a-7a1e-9c3d-5b8e2f1a6d04",
  "type": "token.created",
  "api_version": "v1",
  "occurred_at": "2026-09-18T09:12:04.318Z",
  "published_at": "2026-09-18T09:12:04.492Z",
  "data": {}
}
```

`data` is shown empty here because its contents differ per event type. Fetch the schema for the types you subscribe to from `GET /emsp/v1/webhooks/event_types/{type}`, which returns the versions available and the latest one. Those schemas are served live, so read them from the API rather than copying them into your code.

## Verify the signature

Spirii signs the timestamp and the body together, so a captured delivery can't be replayed later with a fresh timestamp:

```
X-Signature: sha256=HMAC_SHA256(secret, "{X-Webhook-Timestamp}.{raw body}")
```

The secret is the one returned when you created the subscription, or the most recent one if you've rotated it. The digest is lowercase hex, prefixed with `sha256=`.

Sign over the **raw bytes you received**. Parsing the JSON and re-serialising it changes whitespace and key order, and the signature will not match.

<CodeGroup>
  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  const TOLERANCE_SECONDS = 300;

  export function verifyDelivery(rawBody, headers, secret) {
    const timestamp = headers["x-webhook-timestamp"];
    const signature = headers["x-signature"];
    if (!timestamp || !signature) return false;

    // Reject deliveries signed too long ago to be a live request.
    const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
    if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

    const digest = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
    const expected = Buffer.from(`sha256=${digest}`);
    const received = Buffer.from(signature);

    return expected.length === received.length && timingSafeEqual(expected, received);
  }
  ```

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

  TOLERANCE_SECONDS = 300

  def verify_delivery(raw_body: bytes, headers: dict, secret: str) -> bool:
      timestamp = headers.get("x-webhook-timestamp")
      signature = headers.get("x-signature")
      if not timestamp or not signature:
          return False

      # Reject deliveries signed too long ago to be a live request.
      if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
          return False

      mac = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256)
      expected = f"sha256={mac.hexdigest()}"

      return hmac.compare_digest(expected, signature)
  ```
</CodeGroup>

<Warning>
  Compare signatures with a constant-time function, `timingSafeEqual` in Node.js or `hmac.compare_digest` in Python. A plain `==` leaks how much of the signature matched through its timing, which is enough to forge one given sufficient attempts.
</Warning>

The five-minute tolerance above is a suggestion, not something Spirii enforces. Pick a window that suits your infrastructure: too tight and a slow queue rejects legitimate deliveries, too loose and a captured request stays replayable for longer.

Because `event_id` and `X-Webhook-Id` are stable across retries, use either as an idempotency key. A retry after your handler succeeded but its response was lost is indistinguishable from a first delivery, so the same event will sometimes arrive twice.

## Respond to a delivery

Return any `2xx` status to acknowledge a delivery. Anything else, including a timeout, a connection error or a redirect, counts as a failure and is retried.

Acknowledge first and do the work afterwards. With a 10-second timeout, a handler that writes to a slow downstream system before responding will start failing under load, and those failures count against your subscription.

## Retries and failures

A failed delivery is retried up to **five attempts**, on a fixed schedule:

| Attempt | Sent                             |
| ------- | -------------------------------- |
| 1       | Immediately                      |
| 2       | 1 minute after the first attempt |
| 3       | 5 minutes later                  |
| 4       | 30 minutes later                 |
| 5       | 2 hours later                    |

After the fifth attempt the delivery is dead-lettered and that event is not sent again.

<Warning>
  **Five consecutive failures disable the subscription.** `failure_count` counts consecutive failed attempts across all events and resets to `0` on any success, so a single event failing all five attempts is enough to move the subscription from `ACTIVE` to `FAILED`. Deliveries stop at that point, and events that occur while it's disabled are not queued for later.
</Warning>

To bring a disabled subscription back, fix the endpoint, then `PATCH /emsp/v1/webhooks/{webhook_id}` with `status: ACTIVE`. Confirm the endpoint works before you do: `failure_count` only clears on a successful delivery, so re-enabling with the count still at five means the next failure disables it again immediately. Send a [test delivery](#test-deliveries) first.

Pausing is different from failing. A subscription you set to `PAUSED` stays paused until you set it back, and is not counted as unhealthy.

## Test deliveries

`POST /emsp/v1/webhooks/{webhook_id}/test` sends a one-off signed payload to your endpoint and reports whether it was accepted.

The test leaves your subscription alone. It never changes `failure_count` or `status`, so a failing test costs you nothing, and a passing one does not clear a count that has already climbed. Its value is confirming the endpoint is healthy before you re-enable and spend the one delivery you have left.

Two things differ from a real delivery:

* **The body is not the standard envelope.** A test sends `type`, `webhook_id`, `event` and a `data` object holding a fixed message, with no `event_id`, `api_version`, `occurred_at` or `published_at`. `X-Webhook-Id` is `test_{webhook_id}` rather than an event id. A handler that validates the envelope strictly will reject a test and look broken when it isn't.
* **The timeout is 5 seconds**, against 10 for a real delivery. A handler responding close to that limit can fail a test it would have passed in production.

Signing is identical, so a test is a sound way to check your signature verification end to end.

## Monitor your subscription

`GET /emsp/v1/webhooks/{webhook_id}` returns five fields describing delivery health:

| Field              | What it tells you                                                                  |
| ------------------ | ---------------------------------------------------------------------------------- |
| `failure_count`    | Consecutive failures since the last success. At five, the subscription is disabled |
| `last_failure_at`  | When the most recent failure happened                                              |
| `last_success_at`  | When a delivery last succeeded                                                     |
| `last_status_code` | The HTTP status your endpoint last returned, or null if the attempt never got one  |
| `last_delivery_at` | When a delivery was last attempted, successful or not                              |

Alert on `failure_count` rather than on `status`. By the time `status` reads `FAILED` you've already lost the events that occurred during the outage, whereas a count climbing above one or two gives you a window to act.

A `last_status_code` of null alongside a rising `failure_count` points at a connection-level problem, such as an expired certificate or a DNS change, rather than an error your application returned.

## Related

<Columns cols={3}>
  <Card title="Authentication" icon="key-round" href="/api-reference/emsp/authentication">
    Get a token and the scopes needed to manage subscriptions.
  </Card>

  <Card title="API overview" icon="square-terminal" href="/api-reference/overview">
    The Spirii APIs and their path prefixes.
  </Card>

  <Card title="Errors" icon="triangle-alert" href="/developers/errors">
    The status codes the management endpoints return.
  </Card>
</Columns>
