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
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" }'
eventstakes 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 exampleworkflows:read), those events arrive from then on. Subscriptions created in the dashboard receive all events with*.- The key needs
webhooks:manageand the read scope of every family it subscribes to by name or as<family>.*(envelope.*→envelopes:read,document.*→documents:read, …); otherwise 403 / 9002 withdetails.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:
{
"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)
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)
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.statusorrevision, or read the object again withGETwhen in doubt. - Answer quickly: Success is any
2xxresponse within 30 seconds. Do the actual work in the background. - Unknown events: New event names are added over time. Answer unknown ones with
2xxas 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.