← LumenSign API reference Deutsche Fassung

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" }'

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:

HeaderContent
webhook-idEvent ID (equal to id), the same on every attempt
webhook-timestampUnix seconds of this attempt
webhook-signaturev1,<base64 HMAC-SHA256> over <webhook-id>.<webhook-timestamp>.<raw body>; key: the base64-decoded bytes after whsec_
X-QGW-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256> over <t>.<raw body>; key: the whole text whsec_…
X-QGW-Delivery-IdID of this delivery (per subscription, the same on every attempt)
X-QGW-EventName 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

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.