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

# Webhooks

> Receive HMAC-signed event notifications for every state change

Webhooks deliver real-time notifications to your application when events occur in the Custody API. Every webhook delivery is signed with HMAC-SHA256 so you can verify authenticity.

## Registering a webhook endpoint

```bash theme={null}
curl -X POST "$BASE_URL/webhooks" \
  -H "Authorization: Bearer $CUSTODY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/webhooks/custody",
    "events": ["transaction.*", "deposit.*"]
  }'
```

```json theme={null}
{
  "id": "wh_abc123",
  "url": "https://your-app.example.com/webhooks/custody",
  "events": ["transaction.*", "deposit.*"],
  "secret": "whsec_...",
  "status": "active",
  "created_at": "2026-03-19T10:00:00Z"
}
```

<Warning>
  Save the `secret`. It is shown only once. You need it to verify webhook signatures.
</Warning>

## Event types

Qustody emits **12 webhook event types** across four categories.

| Event                        | Trigger                                                         |
| ---------------------------- | --------------------------------------------------------------- |
| `transaction.created`        | New transaction accepted by the API (state `SUBMITTED`)         |
| `transaction.status_changed` | Any transition between active states                            |
| `transaction.completed`      | Transaction reached `COMPLETED`                                 |
| `transaction.failed`         | Transaction reached `FAILED`                                    |
| `deposit.detected`           | Inbound transfer detected on a registered wallet                |
| `deposit.confirmed`          | Inbound transfer confirmed with sufficient depth                |
| `approval.required`          | Policy requires manual approval (state `PENDING_AUTHORIZATION`) |
| `approval.decision`          | An approver called `approve` or `reject`                        |
| `screening.submitted`        | Compliance screening was submitted to the provider              |
| `screening.completed`        | Provider returned a clean result                                |
| `screening.flagged`          | Provider returned a flagged result requiring review             |
| `screening.blocked`          | Provider blocked the transaction (state `BLOCKED`)              |

Use `*` wildcards in event subscriptions. For example, `transaction.*` subscribes to all transaction events; `screening.*` subscribes to every compliance event.

<Tip>
  Pass an empty `events` array (or omit it) to subscribe to **all** event types.
</Tip>

## Webhook payload format

Every delivery includes these headers:

| Header                | Description                            |
| --------------------- | -------------------------------------- |
| `X-Webhook-ID`        | Unique delivery ID                     |
| `X-Webhook-Timestamp` | Unix timestamp of the delivery attempt |
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
| `Content-Type`        | `application/json`                     |

The JSON body follows this structure:

```json theme={null}
{
  "id": "evt_xyz789",
  "type": "transaction.status_changed",
  "timestamp": "2026-03-19T10:05:00Z",
  "data": {
    "transaction_id": "tx_jkl012",
    "status": "COMPLETED",
    "previous_status": "CONFIRMING"
  }
}
```

## HMAC-SHA256 verification

Always verify the `X-Webhook-Signature` header to confirm the payload came from Qustody and hasn't been tampered with.

The signature is computed as:

```
HMAC-SHA256(webhook_secret, timestamp + "." + raw_body)
```

<CodeGroup>
  ```python Python theme={null}
  import hmac
  import hashlib

  def verify_webhook(secret: str, timestamp: str, body: bytes, signature: str) -> bool:
      message = f"{timestamp}.".encode() + body
      expected = hmac.new(
          secret.encode(),
          message,
          hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(expected, signature)

  # In your webhook handler:
  # timestamp = request.headers["X-Webhook-Timestamp"]
  # signature = request.headers["X-Webhook-Signature"]
  # body = request.body (raw bytes)
  # verify_webhook(webhook_secret, timestamp, body, signature)
  ```

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

  function verifyWebhook(secret, timestamp, body, signature) {
    const message = `${timestamp}.${body}`;
    const expected = crypto
      .createHmac("sha256", secret)
      .update(message)
      .digest("hex");
    return crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature)
    );
  }

  // In your Express handler:
  // const timestamp = req.headers["x-webhook-timestamp"];
  // const signature = req.headers["x-webhook-signature"];
  // verifyWebhook(webhookSecret, timestamp, req.rawBody, signature);
  ```

  ```go Go theme={null}
  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "fmt"
  )

  func verifyWebhook(secret, timestamp string, body []byte, signature string) bool {
      message := fmt.Sprintf("%s.%s", timestamp, string(body))
      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write([]byte(message))
      expected := hex.EncodeToString(mac.Sum(nil))
      return hmac.Equal([]byte(expected), []byte(signature))
  }
  ```
</CodeGroup>

<Warning>
  Always use constant-time comparison (`hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node.js, `hmac.Equal` in Go) to prevent timing attacks.
</Warning>

## Retry behavior

Failed deliveries are retried with exponential backoff:

| Attempt | Delay      |
| ------- | ---------- |
| 1       | Immediate  |
| 2       | 30 seconds |
| 3       | 2 minutes  |
| 4       | 10 minutes |
| 5       | 1 hour     |
| 6       | 6 hours    |

After all retries are exhausted, the event is moved to the **dead-letter queue** for manual inspection.

### What counts as a failure?

* HTTP response status outside `200–299`
* Connection timeout (10 seconds)
* DNS resolution failure
* TLS handshake failure

## Managing endpoints

```bash theme={null}
# List all webhook endpoints
curl "$BASE_URL/webhooks" \
  -H "Authorization: Bearer $CUSTODY_API_KEY"

# Delete a webhook endpoint
curl -X DELETE "$BASE_URL/webhooks/{id}" \
  -H "Authorization: Bearer $CUSTODY_API_KEY"
```

## Best practices

<CardGroup cols={2}>
  <Card title="Respond quickly" icon="bolt">
    Return `200 OK` immediately and process the event asynchronously. Slow responses trigger retries.
  </Card>

  <Card title="Handle duplicates" icon="clone">
    Use the `id` field to deduplicate. Retries may deliver the same event multiple times.
  </Card>

  <Card title="Verify signatures" icon="shield-check">
    Always validate HMAC-SHA256 before processing. Reject unsigned or invalid deliveries.
  </Card>

  <Card title="Monitor dead letters" icon="triangle-exclamation">
    Set up alerting on dead-letter events to catch configuration issues early.
  </Card>
</CardGroup>
