Files
accounted/lib/docs/content/cookbook/webhooks.ts
T
Jakob Wennberg ee3c33c7a4 docs(api): correct /docs/api against the v1 implementation (#999)
Audited every endpoint, param, header, request/response field, error code, and
webhook event in the public API docs against the v1 implementation and fixed the
drift; addressed two rounds of CodeRabbit review.

- Error envelope, idempotency, dry-run, and reversal-field corrections.
- Registered the missing articles/dimensions/inbox-items reference resources.
- Cookbook fixes: removed nonexistent endpoints, corrected params/fields, fixed
  the test-key vs live-key quickstart flow and the year-end lock/close sequence.
- Webhooks/changelog: retry window ~87h (incl. route metadata), shipped-vs-
  coming-soon, counts, API-key format, previous_attributes.
- export-docs-to-website.mts absolutises app-served links for the website.

The gnubok-website side is on branch docs/api-correctness (already deployed).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-12 12:57:26 +02:00

189 lines
8.7 KiB
TypeScript

export const COOKBOOK_WEBHOOKS_MD = `# 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](/docs/api/webhooks): 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](https://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.
\`\`\`bash
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:
\`\`\`json
{
"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](https://app.gnubok.se/docs/api/webhooks#nodejs) or [Python](https://app.gnubok.se/docs/api/webhooks#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.
\`\`\`bash
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/webhooks/$WEBHOOK_ID/test" \\
-H "Authorization: Bearer gnubok_sk_test_..."
\`\`\`
Response:
\`\`\`json
{
"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
\`\`\`bash
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:
\`\`\`json
{
"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](/docs/api/webhooks#event-types)). The webhook handler picks up the emission and enqueues a delivery within the same request cycle.
\`\`\`bash
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:
\`\`\`javascript
// 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:
\`\`\`bash
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:
\`\`\`bash
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\`.
`