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

# Webhooks

> Receive decision events, acknowledge them, handle retries and duplicates, and verify their signature.

Specter sends a webhook for every decision to each endpoint subscribed to that event type. Webhooks are how you
receive the final outcome of an
[asynchronous decision](/products/specter/integration/via-api#asynchronous-decision-with-delayed-capture), and they
let other systems react to decisions without polling.

Register an endpoint with its URL and the events it subscribes to (see
[Register webhook](/products/specter/api-reference/webhooks/register-webhook)). Every subscribed endpoint receives
its own copy of each event.

## Events

| Event | Sent when |
| - | - |
| `decision.allow` | A decision is evaluated with the outcome `ALLOW`. |
| `decision.block` | A decision is evaluated with the outcome `BLOCK`. |
| `decision.review` | A decision is evaluated with the outcome `REVIEW`. |
| `decision.provisional` | A decision returns `PROVISIONAL` while asynchronous backend groups are still running. |
| `decision.async.allow` | A provisional decision is finalized as `ALLOW`. |
| `decision.async.block` | A provisional decision is finalized as `BLOCK`. |
| `decision.async.review` | A provisional decision is finalized as `REVIEW`. |

A provisional decision is normally finalized within 30 seconds. If the asynchronous backends have not answered by
then, Specter fails open: it finalizes the decision as `ALLOW` and sends `decision.async.allow`.

Simulations do not send webhooks.

## Payload

Each notification is a `POST` request with a JSON body:

```json theme={null}
{
  "event": "decision.async.block",
  "id": "5f1c2b7e-8d34-4a9b-b0c6-2e7f91d4a8c3",
  "decision_id": "0b9a4f0e-6a2d-4c1e-9f57-3d2c8e1b7a40",
  "occurred_at": "2026-10-14T09:21:07Z",
  "payment_state": null
}
```

| Field | Description |
| - | - |
| `event` | The event type. |
| `id` | Unique ID of the event. It stays the same across retries, so use it to detect duplicates. |
| `decision_id` | The decision the event belongs to. Use it with [Get decision details](/products/specter/api-reference/decisions/get-decision-details) to read the full decision. |
| `occurred_at` | When the event occurred, in ISO 8601 (UTC). |
| `payment_state` | Only on `decision.async.*` events: the payment lifecycle state when the decision was finalized, often `null`. |

See [Event notification](/products/specter/api-reference/event-notification) for the full schema.

## Responding to Webhooks

Specter counts a delivery as successful when your endpoint answers with any `2xx` status code, for example `200`,
`202` or `204`, within 15 seconds.

* **Acknowledge first, process later.** Store the event and return `2xx` right away, then do the work, such as
  capturing or cancelling a payment, asynchronously. A slow response counts as failed and the event is sent again.
* **Keep the response body empty.** Specter ignores the body. If you do return one with
  `content-type: application/json`, it must be valid JSON; otherwise the delivery counts as failed.
* **Every other outcome is a failure.** Any status outside `2xx`, including `4xx`, a timeout, and a connection or
  TLS error, causes a retry. There is no status code that stops retries.
* **Register the final URL.** Specter follows redirects, but a `301`, `302` or `303` redirect turns the request into
  a `GET` without the payload.

Use a publicly reachable HTTPS URL. Specter does not deliver to private or reserved IP addresses; such deliveries
fail and are retried like any other failed delivery.

## Retries

Specter makes up to five attempts per event and endpoint. After a failed attempt, it waits before trying again:

| Attempt | Sent |
| - | - |
| 1 | When the event occurs |
| 2 | About 17 seconds after attempt 1 failed |
| 3 | About 19 seconds after attempt 2 failed |
| 4 | About 23 seconds after attempt 3 failed |
| 5 | About 31 seconds after attempt 4 failed |

Each wait can be up to 10% longer so retries don't arrive in lockstep. The last attempt happens about a minute and a
half after the first.

If the fifth attempt fails, Specter drops the event. The endpoint stays active for later events, but failed
deliveries cannot be listed or sent again. To recover a missed outcome, fetch the decision with
[Get decision details](/products/specter/api-reference/decisions/get-decision-details).

[Updating](/products/specter/api-reference/webhooks/update-webhook) or
[deleting](/products/specter/api-reference/webhooks/delete-webhook) an endpoint affects only events that occur
afterwards. Deliveries that are already queued, including their retries, still go to the previous URL.

## Duplicates and Ordering

The same event can arrive more than once, for example when your endpoint processed it but answered too late.
Retries carry the same `id`, the same body and the same signature, so store the `id` of each processed event and
skip events you have already seen.

Events are not guaranteed to arrive in order:

* A `decision.async.*` event can arrive before the `decision.provisional` event of the same decision.
* It can even arrive before Specter's response to your `POST /api/decisions` request, when the backends answer
  quickly.

Track decisions by `decision_id` and treat a `decision.async.*` event as the final outcome, whenever it arrives. A
`decision.provisional` event never replaces it.

## Verifying Signatures

Every notification carries an `x-hmac-signature` header: the HMAC-SHA256 of the raw request body, encoded as
lowercase hex.

Specter generates the HMAC key when you register the endpoint and returns it only once, as `hmac_key` in the
creation response. Store it securely; later responses show only a masked version. The key cannot be changed: to
rotate it, register a new endpoint and then delete the old one.

Use the key exactly as returned, as a string. It looks like base64, but must not be decoded.

To verify a notification:

1. Calculate the HMAC-SHA256 of the raw request body, before any JSON parsing, with your `hmac_key`.
2. Compare the result with the `x-hmac-signature` header, using a constant-time comparison.

If they match, the notification was sent by Specter and was not modified in transmission.

### Example

For the HMAC key `q3Vt8hZ2mN0xYcLr5pW7sKfA1eJ9uDbG4oTnXiRvEyQ` and this raw body:

```text theme={null}
{"decision_id":"0b9a4f0e-6a2d-4c1e-9f57-3d2c8e1b7a40","event":"decision.async.block","id":"5f1c2b7e-8d34-4a9b-b0c6-2e7f91d4a8c3","occurred_at":"2026-10-14T09:21:07Z","payment_state":null}
```

the `x-hmac-signature` header is:

```text theme={null}
ea60afaf24255af58154f5ec15677f8bc7b3b8e4e2eb921c6ee327fa71d91f7d
```

Verification in Node.js:

```javascript theme={null}
const crypto = require('crypto');

function isValidSignature(rawBody, signature, hmacKey) {
  const expected = crypto.createHmac('sha256', hmacKey).update(rawBody).digest('hex');
  const received = String(signature ?? '');
  return received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
```

Pass the body exactly as received, for example as a `Buffer` from a raw body parser. Re-serializing parsed JSON can
change the bytes and break the signature.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.