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

# Webhooks

> Get order status changes pushed to your endpoint: register it, verify each delivery's Standard Webhooks signature, and handle retries and duplicates safely.

Instead of polling an order's status, register an HTTPS endpoint, and TetraFi sends it an `order.updated` event each time one of your workspace's orders changes status. Deliveries follow [Standard Webhooks](https://www.standardwebhooks.com): every library that implements it can verify them.

> [!NOTE]
>
> The API may change until launch. Every change is listed in the [Changelog](https://tetrafi.mintlify.site/changelog.md).

## Register an endpoint

Webhooks belong to a workspace. Someone with the **Manage credentials and integrations** permission adds one in the app:

1. Open **Integrations** and select the **Webhooks** tab.
2. Select **Add webhook**, enter the **Webhook URL** of your HTTPS endpoint, choose the **Events** it handles, then select **Create webhook**.
3. Copy the signing secret, which starts with `whsec_`, to your secret manager. It is shown once. Tick **I've saved this secret**.
4. Select **Test event** on the webhook to send one signed delivery right away, and check that your endpoint verifies it.

Managing webhooks with an API key that has the `webhooks_manage` scope is rolling out. The endpoints are in the [API reference](https://tetrafi.mintlify.site/reference/introduction.md).

## Events

| Type | Sent when |
| - | - |
| `order.updated` | An order placed in the workspace changes status. |
| `webhook.test` | You select **Test event**. |

[Webhook events](https://tetrafi.mintlify.site/reference/webhook-events.md) lists every event you can subscribe to. More will be added: ignore a `type` you do not handle, and answer it with a `2xx` so it is not retried.

## What a delivery looks like

Each delivery is a `POST` with a JSON body and three headers:

| Header | What it carries |
| - | - |
| `webhook-id` | The delivery's id. A retry of the same delivery carries the same id. |
| `webhook-timestamp` | When the delivery was signed, in Unix seconds. |
| `webhook-signature` | `v1,` followed by the base64 signature. During a secret rotation, two space-separated values. |

The body has the same envelope for every event:

```json Output shape theme={null}
{
  "id": "…",
  "type": "order.updated",
  "apiVersion": "2026-10-29",
  "createdAt": "2026-10-30T09:12:44.000Z",
  "data": { "orderId": "…", "status": "…", "statusUrl": "/api/v1/orders/…" }
}
```

The event is thin: it says that an order changed and where to read it. Read the order at `statusUrl` with your API key for its full state. New fields can appear in the body at any time, so ignore fields you do not know. A change that would break a reader ships under a new `apiVersion`.

## Verify the signature

Verify every delivery before you trust its body. The signature is an HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<raw body>`, keyed with the base64-decoded part of your secret after `whsec_`:

* compute it over the exact bytes you received, before you parse the JSON;
* compare it in constant time;
* refuse a `webhook-timestamp` more than five minutes from your clock, so a captured delivery cannot be replayed later;
* accept the delivery when any one of the `v1,` values matches.

You can verify with a [Standard Webhooks library](https://github.com/standard-webhooks/standard-webhooks) instead of your own code, such as `standardwebhooks` on npm or PyPI: pass it your `whsec_` secret, the raw body and the three headers. Or verify it yourself; these samples check themselves against the Standard Webhooks example delivery, and refuse a changed body and a stale timestamp:

```bash cURL theme={null}
# verify SECRET ID TIMESTAMP BODY SIGNATURES [NOW]
# SECRET is the whsec_… signing secret. ID, TIMESTAMP and SIGNATURES are the webhook-id,
# webhook-timestamp and webhook-signature headers; BODY is the raw request body, before any
# parsing. Succeeds when one signature matches and the timestamp is within 5 minutes of NOW.
verify() {
  local secret=$1 id=$2 timestamp=$3 body=$4 signatures=$5 now=${6:-$(date +%s)}
  (( timestamp > now - 300 && timestamp < now + 300 )) || return 1
  local key expected candidate
  key=$(printf '%s' "${secret#whsec_}" | openssl base64 -d -A | od -An -v -tx1 | tr -d ' \n')
  expected=$(printf '%s.%s.%s' "$id" "$timestamp" "$body" \
    | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$key" -binary | openssl base64 -A)
  # During a secret rotation the header holds two space-separated signatures: either may match.
  for candidate in $signatures; do
    [[ $candidate == "v1,$expected" ]] && return 0
  done
  return 1
}
```

```javascript Node.js theme={null}
// Pass the raw request body, before any JSON parsing, and the three webhook-* headers.
// Returns true when one signature matches and the timestamp is within 5 minutes of now.
function verifyWebhook(secret, headers, rawBody, now = Math.floor(Date.now() / 1000)) {
  const id = headers['webhook-id'];
  const timestamp = Number(headers['webhook-timestamp']);
  if (!id || !Number.isInteger(timestamp) || Math.abs(now - timestamp) >= 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
  // During a secret rotation the header holds two space-separated signatures: either may match.
  return String(headers['webhook-signature'] ?? '').split(' ').some((value) => {
    const [version, signature] = value.split(',');
    const given = Buffer.from(signature ?? '', 'base64');
    return version === 'v1' && given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

```python Python theme={null}
# Pass the raw request body as bytes, before any JSON parsing, and the three webhook-* headers.
# Returns True when one signature matches and the timestamp is within 5 minutes of now.
def verify_webhook(secret, headers, raw_body, now=None):
    now = int(time.time()) if now is None else now
    msg_id = headers.get("webhook-id")
    try:
        timestamp = int(headers.get("webhook-timestamp", ""))
    except ValueError:
        return False
    if not msg_id or abs(now - timestamp) >= 300:
        return False
    key = base64.b64decode(secret[len("whsec_"):] if secret.startswith("whsec_") else secret)
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    # During a secret rotation the header holds two space-separated signatures: either may match.
    for value in headers.get("webhook-signature", "").split(" "):
        version, _, signature = value.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

```json Output theme={null}
{"valid":"accepted","changedBody":"refused","staleTimestamp":"refused"}
```

When you rotate the secret, TetraFi signs with both the new and the previous secret for 24 hours, so a receiver still holding the old one keeps verifying while you deploy the new one.

## Retries

Answer with any `2xx` status within 10 seconds to acknowledge a delivery. Anything else, or no answer in time, is retried after 5 seconds, 30 seconds, 10 minutes, 1 hour, 6 hours and 24 hours.

After five deliveries in a row fail every retry, the webhook is paused. Fix your endpoint, then reactivate the webhook in the app. Each webhook lists its recent deliveries and their outcomes, and you can redeliver one from there.

## Handle each event once

Deliveries are at least once: a retry or a redelivery can bring the same event again, and events can arrive out of order.

* Deduplicate on `webhook-id`: a retry or redelivery of one delivery carries the same `webhook-id`, so store it and skip a delivery you have already handled.
* The body's `id` names the event itself: use it to recognise the same event across deliveries.
* Acknowledge first, work later: answer `2xx` once the delivery is verified and stored, then process it, so slow work does not cause retries.
* To learn an order's current status, read `statusUrl` rather than relying on the order in which events arrived.

[Order status](https://tetrafi.mintlify.site/trade/order-status.md) lists every status an order can read.


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