* feat(api): surface the registry's worked examples in the OpenAPI spec and generated skill EndpointDefinition.example is required and every one of the 125 v1 endpoints populates example.response, but generateOpenApiSpec() never emitted it. The examples reached only the docs markdown builder, so /api/v1/openapi.json carried none and the generated skills/accounted-api had zero json blocks in all 12 reference files: every agent reading the spec or installing the skill got schemas with no concrete body. Emit example on the application/json media types (request body and 200 response) and teach the portable renderOperationMd to print it as a fenced json block. 178 worked examples now reach the skill. SKILL.md is unchanged: the examples land in the on-demand reference files, not the entry file. Attached to JSON media types only, so a multipart body and a binary application/pdf response do not advertise an example they cannot send. Adds the one missing example.request (currency-revaluation) so the new exhaustive coverage assertions hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): emit Retry-After on a v1 429 so the documented contract is real The published accounted-api skill has told agents to honor Retry-After on a 429 since it shipped, but no /api/v1 route ever sent one: the wrapper's auth failure path early-returns through v1ErrorResponseFromCode, whose finalize() set only X-Request-Id and Gnubok-Version. Unattended clients had nothing to pace against and had to back off blindly. 60 seconds is an exact upper bound rather than a guess: the rate limiter is a fixed one-minute tumbling window per key row and the limited branch does not slide it. The value moves into an exported constant next to that limiter, so the MCP server's hardcoded '60' now reads from the same place. Also corrects the withApiV1 doc comment, which claimed step 8 stamps X-RateLimit-Limit. It never did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(mcp): guard the tools/list payload for the namespace new installs get The payload ratchet only ever serialized the gnubok_* projection. The accounted_* projection is inherently larger (every tool reference gains 3 chars, ~209 tokens across the default catalog) and CLAUDE.md points new MCP installs at exactly that namespace, so the payload a new user's client receives was never measured. It had already drifted ~90 tokens past the 63.4K ceiling while the guarded number sat comfortably under it. Measure both and assert on the larger. The ceiling moves to 63.6K to cover the real worst case; this buys no new catalog surface. A second test pins the direction of the delta so Math.max cannot silently stop describing reality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
17 KiB
Webhooks endpoints
HMAC-signed event subscriptions with delivery logs, test pings, retries, and secret rotation.
Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) are in SKILL.md and are not repeated per endpoint.
GET /api/v1/companies/{companyId}/webhooks
List webhook subscriptions for a company.
scope:webhooks:manage · risk:low · idempotent
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. Do not 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.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes |
Response 200:
{
data: {
webhooks: { id: string, name: string, event_type: string, webhook_url: string, active: boolean, api_version_pinned: string, disabled_at: string, disabled_reason: string, created_at: string }[]
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"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"
}
}
POST /api/v1/companies/{companyId}/webhooks
Register a webhook subscription.
scope:webhooks:manage · risk:low · idempotent · dry-run · reversible
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. Do not 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, rotate it with POST /webhooks/{id}/rotate-secret: a fresh secret is issued in place, the webhook id and delivery history are kept.
- 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).
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes |
Request body:
{
event_type: "invoice.created" | "invoice.sent" | "invoice.paid" | "credit_note.created" | "supplier.created" | "supplier_invoice.registered" | "supplier_invoice.approved" | "supplier_invoice.paid" | "supplier_invoice.credited" | "supplier_invoice.uncredited" | "customer.created" | "journal_entry.committed" | "journal_entry.reversed" | "journal_entry.corrected" | "transaction.categorized" | "transaction.reconciled" | "reconciliation.matched" | "reconciliation.unmatched" | "reconciliation.signed_off" | "reconciliation.reopened" | "period.locked" | "period.unlocked" | "period.year_closed" | "salary_run.created" | "salary_run.approved" | "salary_run.booked" | "agi.generated" | "document.uploaded",
webhook_url: string,
name: string,
description?: string
}
Example request:
{
"event_type": "invoice.paid",
"webhook_url": "https://example.com/hooks/gnubok",
"name": "CRM sync"
}
Response 200:
{
data: {
id: string,
name: string,
event_type: string,
webhook_url: string,
active: boolean,
api_version_pinned: string,
disabled_at: string,
disabled_reason: string,
created_at: string,
secret: string,
description: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"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"
}
}
GET /api/v1/companies/{companyId}/webhooks/{id}
Get a webhook subscription by id.
scope:webhooks:manage · risk:low · idempotent
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). Do not use for: Reading the secret (returned only once on creation).
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 200:
{
data: {
id: string,
name: string,
description: string,
event_type: string,
webhook_url: string,
active: boolean,
api_version_pinned: string,
disabled_at: string,
disabled_reason: string,
created_at: string,
updated_at: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"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"
}
}
PATCH /api/v1/companies/{companyId}/webhooks/{id}
Update a webhook subscription.
scope:webhooks:manage · risk:low · idempotent · dry-run · reversible
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. Do not use for: Rotating the signing secret: use POST /webhooks/{id}/rotate-secret, which issues a fresh secret in place and keeps the webhook id and delivery history. Changing event_type: delete and recreate.
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.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Request body:
{ name?: string, description?: string, webhook_url?: string, active?: boolean }
Example request:
{
"active": true
}
Response 200:
{
data: {
id: string,
name: string,
description: string,
event_type: string,
webhook_url: string,
active: boolean,
api_version_pinned: string,
disabled_at: string,
disabled_reason: string,
created_at: string,
updated_at: string
},
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"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 a webhook subscription.
scope:webhooks:manage · risk:medium · idempotent
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. Do not 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.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 204.
GET /api/v1/companies/{companyId}/webhooks/{id}/deliveries
List deliveries for a webhook subscription.
scope:webhooks:manage · risk:low · idempotent
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. Do not 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.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 200:
{
data: { id: string, webhook_id: string, event_type: string, status: "pending" | "in_flight" | "delivered" | "failed" | "dead", attempts: number, next_attempt_at: string, response_status: number, response_body: string, error: string, request_id: string, created_at: string, delivered_at: string }[],
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"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/{id}/rotate-secret
Rotate the HMAC signing secret on a webhook.
scope:webhooks:manage · risk:medium
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. Do not 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.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 200:
{
data: { id: string, secret: string, rotated_at: string },
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"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
Send a synthetic test event to a webhook.
scope:webhooks:manage · risk:low
Enqueues a webhook.test delivery against the configured receiver and dispatches it immediately, so the outcome is normally available within a second or two rather than 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. Do not 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 ~87h (about 3.6 days). Use a 2xx ack-only handler if you want a clean signal.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
companyId |
path | string |
yes | |
id |
path | string |
yes |
Response 200:
{
data: { webhook_delivery_id: string, status: "pending" },
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": {
"webhook_delivery_id": "wh_dlv_…",
"status": "pending"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
POST /api/v1/webhook-deliveries/{id}/retry
Retry a webhook delivery.
scope:webhooks:manage · risk:medium
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. Do not 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).
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
id |
path | string |
yes |
Response 200:
{
data: { webhook_delivery_id: string, status: "pending" },
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
Example response 200:
{
"data": {
"webhook_delivery_id": "wh_dlv_NEW",
"status": "pending"
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}