Webhooks
Webhooks deliver real-time HTTP notifications when domain events happen in Kontorion - invoice finalized, subscription transitioned, credit note applied. Each delivery is HMAC-SHA256-signed, idempotency-keyed, and retried on a fixed schedule until it succeeds or exhausts. Endpoints filter on event type, share secrets via the rotation surface, and have an operator-driven replay path for individual deliveries.
For the catalog of every event type and payload shape, see Webhook Events.
How delivery works
Code
One originating event fans out to every endpoint subscribed to its type for the org. Each endpoint gets its own delivery row keyed on (endpoint_id, idempotency_key) - the dedupe scope means a broker resend or dispatcher retry collapses to a single HTTP call per endpoint.
Setting up an endpoint
Endpoints are configured in two steps: create a signing secret, then register the endpoint that references it.
1. Create a signing secret
Code
The response carries the plaintext signing key - only at creation time:
Code
Store signing_key in your application's secret manager. After this response only key_prefix is ever returned for inspection - the full key is unrecoverable. To rotate, use Rotation.
2. Register the endpoint
Code
event_types is required and must contain at least one entry. Events outside that set are not delivered to this endpoint - wildcard subscriptions don't exist; list every event type explicitly.
secret_id is optional. An endpoint without a secret won't carry an X-Webhook-Signature header - useful for development tunnels but never recommended for production.
Endpoint statuses: active (default) or disabled (skipped by the dispatcher). Mutate an endpoint in place with PATCH /v1/webhook-endpoints/{id} - every field is optional, and only the keys you send are changed:
Code
Patchable fields: url, description, event_types, status (active / disabled), and secret_id. To detach the secret entirely, send "clear_secret": true (which wins over any secret_id in the same request). No delete-and-recreate dance required.
Envelope shape
Every POST body has the same envelope around the typed payload:
Code
| Field | Notes |
|---|---|
id | Per-delivery UUID. Unique to this endpoint's copy of the event - different endpoints subscribed to the same event get different ids. Use idempotency_key (not id) for cross-endpoint dedup |
type | The event type - see Webhook Events |
schema | Payload shape version. Currently "v1". Bumped only for non-additive changes - additive fields are added without a schema bump |
idempotency_key | UUID of the originating action. Stable across broker resends, dispatcher retries, and your endpoint's HTTP retries. The field to deduplicate on |
created_at | ISO 8601 UTC timestamp of the originating event |
payload | Typed body - shape documented per event type |
Verifying signatures
Signed deliveries carry:
Code
Where <hex> is the lowercase hex of HMAC-SHA256(signing_key, raw_body).
Code
Code
Use a constant-time comparison (timingSafeEqual / compare_digest) to avoid timing leaks. Verify against the raw body bytes - JSON re-serialisation will change the hash even when the parsed object is equivalent.
Retry schedule
If your endpoint returns a non-2xx status (or the connection fails), Kontorion retries on a fixed schedule measured from the previous attempt:
| After attempt | Next retry |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 12 hours |
After 6 total attempts the delivery is marked exhausted and stops retrying. The default cap can be tightened (not raised - the schedule is fixed) per organization via the webhook_max_retries setting; once a delivery has the cap stamped on it, lowering the org default does not retroactively shorten in-flight retries.
Delivery statuses: pending → terminal delivered / exhausted, with failed as the transient state between retries.
Idempotency on your side
Your endpoint may receive the same event more than once - broker resends, dispatcher retries, and your own HTTP retries are all part of the at-least-once guarantee. Deduplicate on idempotency_key, not id. id is per-delivery and changes between retries to the same endpoint; idempotency_key is stable for the originating action.
Code
Return 2xx as soon as you've persisted the dedupe row; do downstream work asynchronously. The dispatcher considers your endpoint a failure on a non-2xx response or a hung connection - keep the synchronous path short.
Operator replay
Individual deliveries can be replayed by ID:
Code
This re-POSTs the original envelope with a fresh id (the new delivery row) but the same idempotency_key - your endpoint's dedupe layer treats it correctly. Use it after fixing a downstream bug, never as a fan-out trigger.
GET /v1/webhook-endpoints/{id}/deliveries lists deliveries for an endpoint with their status and last error.
Rotation
To rotate a signing key without dropped deliveries:
POST /v1/webhook-secrets/{id}/rotate- generates a fresh key against the same secret row and returns the new plaintext exactly once- Configure your endpoint to verify against both the old and new key
- Wait for the longest retry window (12h) so any in-flight retries from before the rotation drain
- Drop the old key from your verifier
Deleting a secret (DELETE /v1/webhook-secrets/{id}) takes effect immediately - any endpoint still pointing at it stops getting signed deliveries until you reassign.
Inbound webhooks (other direction)
The reverse path - Kontorion receiving webhooks from external systems - runs through dedicated unauthenticated endpoints with their own signature schemes:
| Endpoint | Source |
|---|---|
POST /v1/webhooks/v2/stripe/{config_id} | Stripe events. {config_id} is the payment_gateway_configs.id (unique across orgs), which resolves the request to a single org + gateway + signing secret with no separate auth header. Still signed with Stripe-Signature against that gateway config's stored webhook_secret - see Payments |
POST /v1/webhooks/einvoicing/peppol/{org_id} | Peppol forwarder lifecycle events (HMAC-authed) |
POST /v1/webhooks/billing-milestones/{id} | Billing-milestone trigger - the one wired org-side inbound channel (see auth below) |
These are inbound only - they don't appear in the outbound delivery surface and have separate auth.
The billing-milestones channel authenticates each request with three headers, all required:
X-Webhook-Timestamp: <RFC3339>- must be within ±5 minutes of server time, or the request is rejected as expired.X-Webhook-Signature: v1=<hex>- lowercase hex HMAC-SHA256. The signed payload istimestamp + "." + raw_body(the timestamp header value, a literal., then the raw request body), not the body alone.
The v2 Stripe path and the Peppol path are not listed in the OpenAPI reference; only POST /webhooks/billing-milestones/{id} is.
Endpoints
Webhook Endpoints, Webhook Secrets, Webhook Deliveries.
Related
- Webhook Events - every event type and its payload shape
- Authentication - Bearer-token setup
- Errors - non-2xx response shape your endpoint should handle