accounteddocs

Cookbook: set up webhooks and verify signatures end-to-end

Subscribe a receiver to invoice events, verify HMAC signatures correctly, handle the at-least-once retry semantics, and build idempotency around the delivery id.

This is the operational companion to the Webhooks concept page: that page explains what webhooks are; this one walks through how to wire one up correctly the first time.

What you'll need

  • A test API key with webhooks:manage scope (and payroll:read if you intend to subscribe to payroll events).
  • A receiver URL that Accounted can POST to. For local development use smee.io or ngrok: Accounted refuses webhook URLs that resolve to private IPs (SSRF protection), so localhost won't work directly.
  • HTTPS only: http:// URLs are rejected at registration.

1. Register the webhook

The response includes the HMAC signing secret exactly once. Capture it immediately and store it on the receiver side as an environment variable.

curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/webhooks" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "invoice.paid",
    "webhook_url": "https://my-receiver.example.com/gnubok",
    "name": "CRM sync: invoice paid"
  }'

Response:

{
  "data": {
    "id": "a8f1e4c2-3b5d-4e6f-8a90-1b2c3d4e5f60",
    "name": "CRM sync: invoice paid",
    "event_type": "invoice.paid",
    "webhook_url": "https://my-receiver.example.com/gnubok",
    "active": true,
    "api_version_pinned": "2026-05-12",
    "secret": "whsec_b3a7c9e2...",
    "created_at": "2026-05-15T12:00:00Z"
  },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}

⚠️ The secret field is returned only on creation. Subsequent GETs never include it. If you lose it, mint a fresh one with POST /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret: it re-issues the signing secret in place (returned exactly once, like creation) without deleting the webhook or its delivery history. Rotation invalidates the old secret immediately — there is no grace period — so stage the new value on the receiver first (or PATCH active=false to pause delivery while you swap), then re-deploy the receiver with the new value.

Store the secret in a secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler, 1Password Connect, ...) rather than a plaintext .env file or a config commit. The secret is signing material: anyone who reads it can forge events that will pass your signature check. Treat it with the same care as a database password.

2. Implement signature verification

Use the Node or Python sample on the concept page. The critical detail: capture the raw request body before any framework JSON-parses it. Re-serialising the body produces different bytes and the signature won't match.

For an Express handler, that means express.raw({ type: 'application/json' }): NOT the default express.json() middleware. For FastAPI / Flask use request.get_data(). For Cloudflare Workers use await request.text() BEFORE request.json().

3. Send a test event

The :test verb enqueues a synthetic webhook.test delivery without driving real state. The dispatcher sends it on the next per-minute cron tick.

curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer gnubok_sk_test_..."

Response:

{
  "data": { "webhook_delivery_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "status": "pending" },
  "meta": { "request_id": "req_...", "api_version": "2026-05-12" }
}

Wait up to 60s, then check the receiver logs. The delivery should arrive with:

POST /gnubok HTTP/1.1
Content-Type: application/json
X-Gnubok-Signature: t=1778846400,v1=2f5c...
X-Gnubok-Event: webhook.test
X-Gnubok-Delivery: f47ac10b-58cc-4372-a567-0e02b2c3d479
X-Gnubok-Api-Version: 2026-05-12

{"id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","type":"webhook.test","api_version":"2026-05-12","created":1778846400,"data":{"object":{"hello":"from Accounted","tested_at":"2026-05-15T12:00:00Z"}},"previous_attributes":null}

If your receiver returns 2xx, the delivery moves to delivered. If it returns 4xx (other than 410) or 5xx, it goes to failed and retries on the schedule 1m / 5m / 30m / 2h / 12h / 24h / 48h.

4. Inspect the delivery

curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/deliveries?delivery_id=$DELIVERY_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..."

Response carries the captured response status and body (truncated to 4 KB), which is invaluable when debugging a 4xx from the receiver:

{
  "data": [{
    "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "event_type": "webhook.test",
    "status": "delivered",
    "attempts": 1,
    "next_attempt_at": "2026-05-15T12:00:00Z",
    "response_status": 200,
    "response_body": "ok",
    "error": null,
    "request_id": "req_...",
    "created_at": "2026-05-15T12:00:00Z",
    "delivered_at": "2026-05-15T12:00:01Z"
  }]
}

5. Drive a real event

Now mark a real invoice paid (or use any of the event-emitting endpoints). The webhook handler picks up the emission and enqueues a delivery within the same request cycle.

curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/mark-paid" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "payment_date": "2026-05-22" }'

The next dispatcher tick (within 60s) delivers an invoice.paid event to your receiver carrying the full invoice payload + payment details.

Idempotency on the receiver side

Deliveries are at-least-once. The same X-Gnubok-Delivery may arrive twice when the network drops a 200 response or your receiver times out after processing. Build idempotency around that header:

// Pseudo-code: adapt to your storage layer.
async function handleEvent(event) {
  const inserted = await db.processedDeliveries.insertIfMissing({
    delivery_id: event.id,
    event_type: event.type,
    received_at: new Date(),
  })
  if (!inserted) {
    console.log('duplicate delivery, skipping', event.id)
    return
  }
  await processBusinessLogic(event)
}

This pattern: a unique constraint on delivery_id, an INSERT-on-conflict-do-nothing, and short-circuit when nothing was inserted. Every Accounted delivery passes through that gate at most once even if the dispatcher retries.

Replaying a dead delivery

When a delivery exhausts its retries it's marked dead. After fixing the receiver, replay individual deliveries with:

curl -X POST "https://app.gnubok.se/api/v1/webhook-deliveries/$DELIVERY_ID/retry" \
  -H "Authorization: Bearer gnubok_sk_test_..."

The retry creates a fresh delivery row pointing at the same payload: the original audit row stays in place. Receivers see a new X-Gnubok-Delivery (the new row's id, not the original's), so the idempotency table needs no special handling.

Auto-disable

After:

  • HTTP 410 Gone from your receiver, OR
  • HTTP 3xx redirect (refused to follow: SSRF policy), OR
  • The webhook URL resolves to a private/loopback/link-local/cloud-metadata IP at dispatch time

…the webhook is automatically disabled (active=false, disabled_reason set). Re-enable with:

curl -X PATCH "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer gnubok_sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "active": true }'

This clears disabled_at and disabled_reason but does NOT replay the deliveries that died while disabled: replay them individually with the retry endpoint.

Common pitfalls

  • Re-serialising the body. JSON.parse(rawBody); JSON.stringify(parsed) produces different bytes than Accounted sent. Always sign-check against the raw bytes.
  • Forgetting the timestamp window. Without a t check, an attacker who captured one signed payload can replay it forever. 5 minutes is the recommended tolerance.
  • Returning 5xx for application errors. A 5xx triggers the full retry schedule (~87h, roughly 3.6 days). If a payload is malformed-but-stable, return 200 and queue for internal investigation.
  • Treating failed as terminal. failed rows will retry; only delivered and dead are terminal. Don't alert on failed: alert when retries exhaust to dead.