Webhooks
Subscribe to events with HMAC-signed delivery, exponential retries, and dead-letter replay.
Endpoints
GET/api/v1/companies/:companyId/webhooks— List webhook subscriptions for a company.GET/api/v1/companies/:companyId/webhooks/:id— Get a webhook subscription by id.GET/api/v1/companies/:companyId/webhooks/:id/deliveries— List deliveries for a webhook subscription.POST/api/v1/companies/:companyId/webhooks— Register a webhook subscription.POST/api/v1/companies/:companyId/webhooks/:id/rotate-secret— Rotate the HMAC signing secret on a webhook.POST/api/v1/companies/:companyId/webhooks/:id/test— Send a synthetic test event to a webhook.POST/api/v1/webhook-deliveries/:id/retry— Retry a webhook delivery.PATCH/api/v1/companies/:companyId/webhooks/:id— Update a webhook subscription.DELETE/api/v1/companies/:companyId/webhooks/:id— Delete a webhook subscription.
GET /api/v1/companies/:companyId/webhooks {#get-webhooks-list}
webhooks.list · scope webhooks:manage
List webhook subscriptions for a company.
Returns all webhook subscriptions for the company. The HMAC signing secret is never exposed by this endpoint — it is returned exactly once when the webhook is created.
Use when: You need to enumerate the webhook subscriptions an integration has registered, e.g. to build a UI listing or sync state with an external system.
Don't use for: Reading delivery history (use GET /webhooks/{id}/deliveries). Reading the secret (it is unrecoverable after the create response — generate a new webhook if lost).
Pitfalls
- Disabled webhooks (auto-disabled after HTTP 410, or manually disabled via PATCH) appear in the list with active=false and a disabled_reason.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Example response
{
"data": {
"webhooks": [
{
"id": "a8f1…",
"name": "CRM sync",
"event_type": "invoice.paid",
"webhook_url": "https://example.com/hooks/gnubok",
"active": true,
"api_version_pinned": "2026-05-12",
"disabled_at": null,
"disabled_reason": null,
"created_at": "2026-05-15T12:00:00Z"
}
]
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
GET /api/v1/companies/:companyId/webhooks/:id {#get-webhooks-get}
webhooks.get · scope webhooks:manage
Get a webhook subscription by id.
Returns the webhook configuration. The HMAC signing secret is never exposed.
Use when: You need the current state of a single webhook (e.g. to render a settings page).
Don't use for: Reading the secret (returned only once on creation).
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Example response
{
"data": {
"id": "a8f1…",
"name": "CRM sync",
"description": null,
"event_type": "invoice.paid",
"webhook_url": "https://example.com/hooks/gnubok",
"active": true,
"api_version_pinned": "2026-05-12",
"disabled_at": null,
"disabled_reason": null,
"created_at": "2026-05-15T12:00:00Z",
"updated_at": "2026-05-15T12:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
GET /api/v1/companies/:companyId/webhooks/:id/deliveries {#get-webhooks-deliveries-list}
webhooks.deliveries.list · scope webhooks:manage
List deliveries for a webhook subscription.
Returns deliveries for the webhook in newest-first order. Each row carries the current status (pending / in_flight / delivered / failed / dead), the attempt count, the next scheduled retry time, and the captured response details from the last attempt.
Use when: You are debugging a flaky receiver, or building a delivery-history UI for a settings page.
Don't use for: Listing deliveries across multiple webhooks (this endpoint is single-webhook scoped).
Pitfalls
- response_body is truncated to 4 KB — receivers returning long error pages have their response truncated.
- A delivery in
failedstatus is non-terminal — the dispatcher will retry it at next_attempt_at.deadis terminal.
Risk: low · Idempotent: yes · Reversible: no · Dry-run supported: no
Example response
{
"data": [
{
"id": "wh_dlv_…",
"webhook_id": "a8f1…",
"event_type": "invoice.paid",
"status": "delivered",
"attempts": 1,
"next_attempt_at": "2026-05-15T12:00:00Z",
"response_status": 200,
"response_body": "ok",
"error": null,
"request_id": "whdel_…",
"created_at": "2026-05-15T12:00:00Z",
"delivered_at": "2026-05-15T12:00:01Z"
}
],
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12",
"next_cursor": null
}
}
POST /api/v1/companies/:companyId/webhooks {#post-webhooks-create}
webhooks.create · scope webhooks:manage
Register a webhook subscription.
Creates a webhook subscription for one event type. The response includes a freshly generated HMAC signing secret, returned EXACTLY ONCE — store it on the receiver side immediately. The webhook is pinned to the current API version on creation; payload shapes for this webhook will not change until you explicitly upgrade.
Use when: You are wiring a downstream integration that needs push notifications instead of polling.
Don't use for: Subscribing to internal MCP telemetry events (mcp.tool_called etc. are not delivered as webhooks). Replacing an existing webhook URL — use PATCH instead.
Pitfalls
- The secret is returned exactly once. If lost, delete and recreate the webhook.
- Delivery is at-least-once with exponential backoff (1m / 5m / 30m / 2h / 12h / 24h / 48h). Receivers MUST be idempotent.
- HTTP 410 from your receiver auto-disables the webhook (sets active=false + disabled_reason).
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Example request
{
"event_type": "invoice.paid",
"webhook_url": "https://example.com/hooks/gnubok",
"name": "CRM sync"
}
Example response
{
"data": {
"id": "a8f1…",
"name": "CRM sync",
"event_type": "invoice.paid",
"webhook_url": "https://example.com/hooks/gnubok",
"active": true,
"api_version_pinned": "2026-05-12",
"disabled_at": null,
"disabled_reason": null,
"secret": "whsec_…",
"description": null,
"created_at": "2026-05-15T12:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
POST /api/v1/companies/:companyId/webhooks/:id/rotate-secret {#post-webhooks-rotate_secret}
webhooks.rotate_secret · scope webhooks:manage
Rotate the HMAC signing secret on a webhook.
Generates a fresh HMAC signing secret for the webhook and returns it EXACTLY ONCE. The previous secret is invalidated immediately. There is no grace period — coordinate the rotation on the receiver side BEFORE calling this endpoint, or temporarily disable the webhook (PATCH active=false) to pause delivery while you swap secrets.
Use when: After a suspected secret leak, on a routine rotation cadence (Stripe pattern: every 90 days for compliance-grade integrations), or when changing the receiver implementation and you want to invalidate the old secret deliberately.
Don't use for: Routine integration setup — the secret returned at create time is the canonical one. Recovering a lost secret (rotation does not recover the prior value; it issues a fresh one).
Pitfalls
- The secret is returned exactly once. If you lose this response, the recovery path is to rotate again.
- In-flight deliveries between the rotation and the receiver-side update may fail signature verification on the new secret. Pause the webhook (PATCH active=false) first if your tolerance for that window is zero.
Risk: medium · Idempotent: no · Reversible: no · Dry-run supported: no
Example response
{
"data": {
"id": "a8f1…",
"secret": "whsec_…",
"rotated_at": "2026-05-15T12:00:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
POST /api/v1/companies/:companyId/webhooks/:id/test {#post-webhooks-test}
webhooks.test · scope webhooks:manage
Send a synthetic test event to a webhook.
Enqueues a webhook.test delivery against the configured receiver. The dispatcher delivers it on the next per-minute cron tick. Use the returned webhook_delivery_id to poll GET /webhooks/{id}/deliveries for the outcome.
Use when: After creating or modifying a webhook, before relying on it in production — to validate that the receiver is reachable and that signature verification works on the receiver side.
Don't use for: Smoke-testing the dispatcher itself (use a real event). Replaying a failed delivery (use POST /webhook-deliveries/{id}/retry).
Pitfalls
- Test deliveries follow the same retry policy as real events — a 500 from your receiver will retry 7 times over ~72h. Use a 2xx ack-only handler if you want a clean signal.
Risk: low · Idempotent: no · Reversible: no · Dry-run supported: no
Example response
{
"data": {
"webhook_delivery_id": "wh_dlv_…",
"status": "pending"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
POST /api/v1/webhook-deliveries/:id/retry {#post-webhook_deliveries-retry}
webhook_deliveries.retry · scope webhooks:manage
Retry a webhook delivery.
Re-enqueues a dead (or delivered) delivery as a fresh pending row. The new delivery references the same webhook + payload; the dispatcher picks it up at the next per-minute cron tick. The original row is preserved in the audit log.
Use when: After a receiver outage you want to replay deliveries that died, or after fixing a receiver-side bug you want to redeliver a successful one.
Don't use for: Retrying live deliveries (pending / in_flight / failed) — the dispatcher is already managing them.
Pitfalls
- Retrying a delivered delivery causes the receiver to see the event twice. Receivers MUST be idempotent (check the X-Gnubok-Delivery header).
Risk: medium · Idempotent: no · Reversible: no · Dry-run supported: no
Example response
{
"data": {
"webhook_delivery_id": "wh_dlv_NEW",
"status": "pending"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
PATCH /api/v1/companies/:companyId/webhooks/:id {#patch-webhooks-update}
webhooks.update · scope webhooks:manage
Update a webhook subscription.
Update the URL, name, description, or active flag. event_type is immutable — delete and recreate to change it. Setting active=false manually pauses delivery without deleting; setting active=true clears any disabled_at/disabled_reason set by the auto-disable on HTTP 410.
Use when: You need to point an existing webhook at a new URL or temporarily pause delivery.
Don't use for: Rotating the signing secret (delete and recreate). Changing event_type.
Pitfalls
- Re-enabling a webhook (active: true) does NOT replay deliveries that went to dead status while it was disabled — those need POST /webhook-deliveries/{id}/retry.
Risk: low · Idempotent: yes · Reversible: yes · Dry-run supported: yes
Example request
{
"active": true
}
Example response
{
"data": {
"id": "a8f1…",
"name": "CRM sync",
"description": null,
"event_type": "invoice.paid",
"webhook_url": "https://example.com/hooks/gnubok",
"active": true,
"api_version_pinned": "2026-05-12",
"disabled_at": null,
"disabled_reason": null,
"created_at": "2026-05-15T12:00:00Z",
"updated_at": "2026-05-15T12:05:00Z"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
DELETE /api/v1/companies/:companyId/webhooks/:id {#delete-webhooks-delete}
webhooks.delete · scope webhooks:manage
Delete a webhook subscription.
Hard-deletes the webhook. The delivery audit trail SURVIVES — both terminal (delivered, dead) and non-terminal (pending, failed) delivery rows persist with webhook_id = NULL so the BFNAR 2013:2 kap 8 § behandlingshistorik (7-year retention) for accounting-event deliveries is preserved. Non-terminal rows go dormant (the dispatcher skips them).
Use when: You no longer want this webhook to receive events.
Don't use for: Temporarily pausing delivery — use PATCH with active=false instead so the configuration survives.
Pitfalls
- Audit history survives DELETE; only the receiver subscription is removed. To suppress future events without retaining the registration use PATCH active=false.
Risk: medium · Idempotent: yes · Reversible: no · Dry-run supported: no
Example response
{
"data": {
"deleted": true
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}