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

# Webhook overview

> Subscribe to Partner events, understand the common envelope, and verify every delivery safely.

Webhooks notify your application after deposits, withdrawals, and exchange
orders change. REST remains the source of truth, so use the identifiers in a
webhook to retrieve current state whenever your workflow needs confirmation.

## Event type guides

* [Deposit events](/docs/webhook-deposit-events) cover required Travel Rule
  information and terminal deposit outcomes.
* [Withdrawal events](/docs/webhook-withdrawal-events) cover terminal withdrawal
  outcomes.
* [Exchange events](/docs/webhook-exchange-events) cover order acceptance,
  fills, replacement, cancellation, and rejection.

Every delivery uses the same five-field top-level envelope. The linked event
guides contain complete, schema-valid JSON bodies for each `data` shape.

| Field         | Description                                                                                       |
| ------------- | ------------------------------------------------------------------------------------------------- |
| `id`          | Stable event identifier. Use this value, or the identical `webhook-id` header, for deduplication. |
| `type`        | Exact subscribed event name.                                                                      |
| `version`     | Payload schema version. The current version is `1`.                                               |
| `occurred_at` | RFC 3339 time of the durable source transition.                                                   |
| `data`        | Event-specific public resource snapshot described in the event type guides.                       |

The body does not contain endpoint, delivery-attempt, signing-secret, or account
routing metadata.

## Subscribe to events

Create an HTTPS endpoint with `POST /v1/partner/webhooks`. An account can have
at most three enabled endpoints. Subscriptions are exact event names; wildcard
subscriptions are not supported.

* `deposit.action_required`
* `deposit.success`
* `deposit.failed`
* `withdrawal.success`
* `withdrawal.failed`
* `exchange.accepted`
* `exchange.filled`
* `exchange.replaced`
* `exchange.cancelled`
* `exchange.rejected`

Create the endpoint from your server and store the returned secret immediately:

```bash theme={null}
curl "$STABYL_BASE_URL/partner/webhooks" \
  -X POST \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $STABYL_API_KEY" \
  -d '{
    "url": "https://partner.example.com/stabyl/events",
    "description": "Stabyl event receiver",
    "enabled": true,
    "event_types": [
      "deposit.action_required",
      "deposit.success",
      "deposit.failed",
      "withdrawal.success",
      "withdrawal.failed"
    ]
  }'
```

```json theme={null}
{
  "status": "success",
  "data": {
    "webhook": {
      "id": "01980d95-5537-76aa-88a8-ad59117632ef",
      "url": "https://partner.example.com/stabyl/events",
      "description": "Stabyl event receiver",
      "status": "enabled",
      "event_types": [
        "deposit.action_required",
        "deposit.success",
        "deposit.failed",
        "withdrawal.success",
        "withdrawal.failed"
      ],
      "secret_version": 1,
      "created_at": "2026-07-17T11:00:00Z",
      "updated_at": "2026-07-17T11:00:00Z"
    },
    "secret": "whsec_<shown-once>"
  }
}
```

Your API key must have `wallets:view` for deposit and withdrawal topics, and
`trades:view` for exchange topics, in addition to the webhook-management scope
required to create or update an endpoint.

The create response contains a generated `whsec_...` signing secret exactly
once. Store it in your secrets manager. Rotating a secret also returns the new
value exactly once. During the 24-hour rotation overlap, the signature header
contains signatures for both the new and previous secret; accept the delivery
when any one signature verifies with a secret you currently trust.

## Manage an endpoint

Use the endpoint ID returned at creation:

```bash theme={null}
# List endpoints
curl "$STABYL_BASE_URL/partner/webhooks" \
  -H "X-Api-Key: $STABYL_API_KEY"

# Replace the URL, description, enabled state, and complete subscription list
curl "$STABYL_BASE_URL/partner/webhooks/$STABYL_WEBHOOK_ID" \
  -X PUT \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: $STABYL_API_KEY" \
  -d '{
    "url": "https://partner.example.com/stabyl/events",
    "description": "Stabyl event receiver",
    "enabled": true,
    "event_types": ["deposit.success", "withdrawal.success", "withdrawal.failed"]
  }'

# Rotate the signing secret; store the new secret from this response
curl "$STABYL_BASE_URL/partner/webhooks/$STABYL_WEBHOOK_ID/secret/rotate" \
  -X POST \
  -H "X-Api-Key: $STABYL_API_KEY"
```

`PUT` replaces the complete endpoint configuration, including the subscription
list. To stop deliveries without deleting history, update the endpoint with
`enabled: false`. Use `DELETE /partner/webhooks/{webhook_id}` only when the
endpoint is no longer needed.

There is no synthetic test-delivery endpoint. Validate your receiver in staging
by completing a staging workflow that produces one of its subscribed events,
then confirm the event and delivery through the history endpoints. Endpoint
creation by itself does not test network delivery.

## Delivery contract

Stabyl sends the exact JSON body with these headers:

```text theme={null}
webhook-id: <stable event ID>
webhook-timestamp: <Unix seconds>
webhook-signature: v1,<base64 HMAC> [v1,<base64 HMAC>]
x-stabyl-delivery-id: <stable endpoint delivery ID>
x-stabyl-event-type: <event type>
x-stabyl-delivery-attempt: <logical attempt number>
```

Calculate the signature over the unmodified request bytes:

```text theme={null}
webhook-id + "." + webhook-timestamp + "." + raw request body
```

Use HMAC-SHA256 with the 32 bytes obtained by removing `whsec_` and decoding
the remaining standard Base64 text. Compare signatures in constant time and
reject timestamps outside your chosen tolerance. The examples below use five
minutes.

<Warning>
  Read the raw body before any JSON middleware changes whitespace, key order,
  or encoding. Parse JSON only after signature verification succeeds.
</Warning>

## Node.js verification

```js theme={null}
import crypto from "node:crypto";
import express from "express";

function decodeBase64(value) {
  if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
    throw new Error("invalid base64");
  }
  const decoded = Buffer.from(value, "base64");
  if (decoded.toString("base64") !== value) throw new Error("non-canonical base64");
  return decoded;
}

function verifyStabylWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const webhookId = headers["webhook-id"];
  const timestampText = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];
  if (!webhookId || !/^\d+$/.test(timestampText ?? "") || !signatureHeader) {
    throw new Error("missing webhook headers");
  }

  const timestamp = Number(timestampText);
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > toleranceSeconds) {
    throw new Error("stale webhook");
  }
  if (!secret.startsWith("whsec_")) throw new Error("invalid secret");
  const key = decodeBase64(secret.slice(6));
  if (key.length !== 32) throw new Error("invalid secret");

  const expected = crypto
    .createHmac("sha256", key)
    .update(webhookId, "utf8")
    .update(".")
    .update(timestampText, "utf8")
    .update(".")
    .update(rawBody)
    .digest();

  const valid = signatureHeader.split(/\s+/).some((candidate) => {
    const comma = candidate.indexOf(",");
    if (comma < 0 || candidate.slice(0, comma) !== "v1") return false;
    try {
      const supplied = decodeBase64(candidate.slice(comma + 1));
      return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
    } catch {
      return false;
    }
  });
  if (!valid) throw new Error("invalid webhook signature");

  return { webhookId, event: JSON.parse(rawBody.toString("utf8")) };
}

const app = express();
app.post("/stabyl-webhooks", express.raw({ type: "application/json" }), async (req, res) => {
  const { webhookId, event } = verifyStabylWebhook(
    req.body,
    req.headers,
    process.env.STABYL_WEBHOOK_SECRET,
  );

  // Keep this insert and your business change in one database transaction.
  // webhook-id must have a unique constraint so a duplicate performs no work.
  await db.transaction(async (tx) => {
    const firstDelivery = await tx.insertWebhookInboxIfAbsent(webhookId);
    if (firstDelivery) await applyEventInSameTransaction(tx, event);
  });
  res.sendStatus(204);
});
```

## Python verification

```python theme={null}
import base64
import binascii
import hashlib
import hmac
import json
import time

from fastapi import FastAPI, Header, Request, Response


def verify_stabyl_webhook(
    raw_body: bytes,
    webhook_id: str,
    timestamp_text: str,
    signature_header: str,
    secret: str,
    tolerance_seconds: int = 300,
):
    if not timestamp_text.isascii() or not timestamp_text.isdigit():
        raise ValueError("invalid timestamp")
    timestamp = int(timestamp_text)
    if abs(int(time.time()) - timestamp) > tolerance_seconds:
        raise ValueError("stale webhook")
    if not secret.startswith("whsec_"):
        raise ValueError("invalid secret")

    key = base64.b64decode(secret.removeprefix("whsec_"), validate=True)
    if len(key) != 32:
        raise ValueError("invalid secret")
    signed = webhook_id.encode() + b"." + timestamp_text.encode() + b"." + raw_body
    expected = hmac.new(key, signed, hashlib.sha256).digest()

    valid = False
    for candidate in signature_header.split():
        try:
            version, encoded = candidate.split(",", 1)
            supplied = base64.b64decode(encoded, validate=True)
        except (ValueError, binascii.Error):
            continue
        if version == "v1" and hmac.compare_digest(supplied, expected):
            valid = True
    if not valid:
        raise ValueError("invalid webhook signature")
    return json.loads(raw_body), webhook_id


app = FastAPI()


@app.post("/stabyl-webhooks")
async def receive_webhook(
    request: Request,
    webhook_id: str = Header(alias="webhook-id"),
    webhook_timestamp: str = Header(alias="webhook-timestamp"),
    webhook_signature: str = Header(alias="webhook-signature"),
):
    raw_body = await request.body()
    event, event_id = verify_stabyl_webhook(
        raw_body,
        webhook_id,
        webhook_timestamp,
        webhook_signature,
        STABYL_WEBHOOK_SECRET,
    )

    # Use a unique webhook_id inbox row and apply the business change in the
    # same transaction. A duplicate then commits no second side effect.
    async with db.transaction() as tx:
        first_delivery = await tx.insert_webhook_inbox_if_absent(event_id)
        if first_delivery:
            await apply_event_in_same_transaction(tx, event)
    return Response(status_code=204)
```

## Retries, duplicates, and ordering

A failed initial delivery can receive up to three retries. The nominal retry
delays are approximately 30 seconds, 5 minutes, and 30 minutes, with jitter.
Stabyl retries transient network failures and HTTP `408`, `409`, `425`, `429`,
and `5xx` responses. Other `4xx` responses and redirects are not retried. A
valid `Retry-After` response can delay a retry by up to 30 minutes.

Network ambiguity can repeat the same logical attempt, so always deduplicate by
`webhook-id`. No ordering is guaranteed across events, endpoints, topics, or
retries. Return any `2xx` response only after your durable inbox transaction is
committed.

Use `GET /v1/partner/webhook/events` and
`GET /v1/partner/webhook/deliveries` for eventually consistent delivery
history. History may lag live delivery and does not replace REST reconciliation.
