# Receiving and verifying webhooks

Webhooks tell your system what happened: an envelope was signed, a document was checked, a certificate is about to expire. This guide shows how to create a subscription, verify every delivery and process events exactly once. The full list of events with body and example is in the API reference under "Webhooks" and at `GET /v1/event-types`.

This guide is the English version of the German guide `webhooks` and covers the same steps.

## 1. Create a subscription

```sh
curl -X POST "$BASE_URL/v1/webhook-subscriptions" \
  -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: crm-subscription-1' \
  -d '{ "url": "https://example.com/hooks/signatures", "events": ["envelope.completed", "recipient.*"], "description": "CRM" }'
```

- `events` takes names (`envelope.completed`), families (`recipient.*`) or `*`. `*` means: every event that the key of the subscription may read. Events of other families do not arrive; if the key later gets another read scope (for example `workflows:read`), those events arrive from then on. Subscriptions created in the dashboard receive all events with `*`.
- The key needs `webhooks:manage` and the read scope of every family it subscribes to by name or as `<family>.*` (`envelope.*` → `envelopes:read`, `document.*` → `documents:read`, …); otherwise 403 / 9002 with `details.missingScopes`. For `*`, a read scope of any one family is enough.
- If another key changes the URL or the events of a subscription with `*`, it needs the read scopes of all families.
- The URL must be `https://` on port 443 or 8443 and point to a public address.
- A test key creates a subscription for test events, a live key one for live events. Up to 25 subscriptions per mode.

The response (201) contains `secret` (`whsec_…`) **this one time only**. If you lost it, create a new one with `POST /v1/webhook-subscriptions/{id}/rotate-secret`.

To try things out, `POST /v1/webhook-subscriptions/{id}/test` sends a signed test delivery (`"test": true` in the body).

## 2. What arrives

Every delivery is a `POST` with a JSON body:

```json
{
  "id": "0192f1c2-7a10-7c3e-9d2b-3f0a1b2c3d4e",
  "object": "event",
  "type": "envelope.completed",
  "event": "envelope.completed",
  "apiVersion": "1",
  "createdAt": "2026-11-03T09:15:02.113Z",
  "orgId": "0190a000-0000-7000-8000-000000000001",
  "mode": "TEST",
  "subscriptionId": "0192e0aa-0000-7000-8000-00000000000a",
  "context": { "envelopeId": "0192f1b0-0000-7000-8000-000000000101", "externalId": "crm-4711" },
  "data": { "object": "envelope", "id": "0192f1b0-0000-7000-8000-000000000101", "status": "COMPLETED" }
}
```

`data` is the object of the matching `GET` route at the time of the event (shortened here), `context` names the references and your `externalId`, and `previous` (if present) holds the changed fields with their old value.

Two sets of signature headers come with it, computed over the same raw body. Verifying **one** of them is enough:

| Header | Content |
| --- | --- |
| `webhook-id` | Event ID (equal to `id`), the same on every attempt |
| `webhook-timestamp` | Unix seconds of this attempt |
| `webhook-signature` | `v1,<base64 HMAC-SHA256>` over `<webhook-id>.<webhook-timestamp>.<raw body>`; key: the base64-decoded bytes after `whsec_` |
| `X-QGW-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` over `<t>.<raw body>`; key: the whole text `whsec_…` |
| `X-QGW-Delivery-Id` | ID of this delivery (per subscription, the same on every attempt) |
| `X-QGW-Event` | Name of the event |

During a secret rotation, both signature headers carry one value per valid secret; accept the request if one of them matches.

## 3. Verify the signature

Always verify against the **raw body** (the received bytes, before JSON is parsed), reject timestamps older than five minutes, and compare in constant time.

### Node.js (Standard Webhooks)

```ts
import express from 'express';
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.WEBHOOK_SECRET!); // whsec_…
const app = express();

app.post('/hooks/signatures', express.raw({ type: 'application/json' }), async (req, res) => {
  let event: { id: string; type: string };
  try {
    event = wh.verify(req.body, {
      'webhook-id': req.header('webhook-id') ?? '',
      'webhook-timestamp': req.header('webhook-timestamp') ?? '',
      'webhook-signature': req.header('webhook-signature') ?? '',
    }) as { id: string; type: string };
  } catch {
    return res.status(400).end();
  }
  if (await alreadyProcessed(event.id)) return res.status(204).end();
  await enqueue(event); // work happens in the background
  res.status(204).end();
});
```

### Python (`X-QGW-Signature`, standard library only)

```python
import hashlib, hmac, time

def verify_qgw_signature(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    timestamp, signatures = None, []
    for part in (header or "").split(","):
        key, _, value = part.partition("=")
        if key == "t" and value.isdigit():
            timestamp = int(value)
        elif key == "v1" and value:
            signatures.append(value)
    if timestamp is None or not signatures or abs(time.time() - timestamp) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in signatures)
```

In Flask, `request.get_data()` returns the raw body, in FastAPI `await request.body()`. For the `webhook-*` header set there are also the official Standard Webhooks libraries for Python (`pip install standardwebhooks`), Go, Java and other languages.

## 4. Process exactly once

- **At least once:** The same event can arrive more than once (for example when your response was lost). Store `webhook-id` (= `id`) and ignore repeats.
- **Order:** Deliveries do not necessarily arrive in the order of the events. Decide by `createdAt`, `data.status` or `revision`, or read the object again with `GET` when in doubt.
- **Answer quickly:** Success is any `2xx` response within 30 seconds. Do the actual work in the background.
- **Unknown events:** New event names are added over time. Answer unknown ones with `2xx` as well.

## 5. Retries and errors

Any other response, a timeout or a network error leads to further attempts (for subscriptions up to 5 attempts with growing intervals of up to 8 minutes). Redirects (`3xx`) are not followed and count as failures. `410 Gone` ends the delivery and disables the subscription.

You can read the delivery log with `GET /v1/webhook-subscriptions/{id}/deliveries` and `GET /v1/webhook-deliveries/{id}`; `POST /v1/webhook-deliveries/{id}/retry` delivers a delivery again (same `webhook-id`). What your endpoint missed is available for 30 days at `GET /v1/events`.

After 10 permanently failed deliveries in a row over at least 24 hours, the subscription is paused; `PATCH /v1/webhook-subscriptions/{id}` with `{ "state": "ACTIVE" }` resumes it.
