Concepts
Webhooks
Outbound events on workflow transitions. Event types, payload, delivery semantics.
Webhooks
Tidenda fires outbound webhooks when documents transition through workflow statuses. An organisation registers an endpoint, picks which document type and statuses to subscribe to, and receives HMAC-signed POSTs with the document data whenever matching events occur.
This page is the conceptual reference: what fires when, what the payload contains, and how delivery and retries behave. For the receiver-side recipe (signature verification, deduplication, response expectations), see Receiving webhooks. For the cryptographic details of the snapshot signature, see Signing and signatures.
Event types
| Event | When it fires |
|---|---|
document.draft |
Document transitioned to draft |
document.usable |
Document transitioned to usable (i.e. published) |
document.withheld |
Document transitioned to withheld (scheduled release) |
document.cancelled |
Document transitioned to cancelled (unpublished) |
document.<custom> |
Any other status name the workflow defines |
The event name is document.<status> where <status> is the target
status the document is transitioning to. Custom workflow statuses
produce custom event names; subscribe to them through the multi-select
in the webhook configuration UI.
Webhooks fire on the transition into the status, not on every
save while in that status. Saves to a usable document do not refire
document.usable; only the transition that took it into usable
does.
Workflow documents themselves (core/workflow) do not emit webhook
events. The lifecycle of workflow configuration is tenant-internal
and isn't useful to broadcast.
Payload
A POST to the registered URL with Content-Type: application/json:
{
"id": "01J5K2V7Q3M9R8XKZP4F8YQN2H",
"type": "document.usable",
"occurredAt": "2026-05-29T08:42:31.402Z",
"tenantId": "421fb813-f96e-4edc-8b88-95d2c76665a4",
"webhookId": "8a3e2c40-3f12-4f6d-902b-6e2f4ad9bb91",
"document": {
"id": "3b2c1a40-f128-4cb6-b8c2-19a4d8d7e2f5",
"type": "core/article",
"version": 3,
"status": "usable",
"title": "Anchored in the harbour",
"snapshotBytes": "<base64 JSON>",
"snapshotSignature": {
"alg": "ed25519",
"keyId": "void-2026-05",
"sig": "<base64>"
}
}
}
The id is identical to the X-Void-Event-Id header and is the
dedup key receivers should use.
document.snapshotBytes carries the immutable snapshot JSON inline,
base64-encoded. Decode → utf-8 → JSON.parse to get the full
document. No second HTTP fetch is needed.
document.snapshotSignature covers the decoded snapshot bytes. See
Signing and signatures for the verification
recipe.
document.type reflects the document type (single value, set on the
webhook). document.status reflects the new status.
snapshotBytes and snapshotSignature are null together for events
that don't produce a snapshot (transitions into non-snapshotted
statuses like draft or cancelled).
The platform caps an embedded snapshot at 2 MB of raw JSON (before base64). Beyond that, the event is not delivered and the admin UI shows a permanent-failure entry — the platform refuses to guess whether an oversized payload would be safe to truncate.
Payload modes
A webhook subscribes in one of two modes:
full(default) — as shown above. InlinesnapshotBytes+snapshotSignature, plus theembeddedAssetsprojection. Zero-fetch delivery. Chosen automatically for new subscribers.compact— omitssnapshotBytes,snapshotSignatureandembeddedAssetsentirely. The delivery is only the transition envelope. Subscribers that want snapshot content fetch it themselves — typically by pairing the webhook with apublisher:readAPI key againstGET /v1/documents.
Choose compact when your receiver:
- Only needs the transition fact (fanout to a queue, cache- invalidation notification, "something changed" ping), or
- Already has an API key for content and wants to keep webhook bandwidth minimal, or
- Handles very large documents where inline base64 pushes deliveries past the 2 MB snapshot cap.
A compact delivery looks like:
{
"id": "01J5K2V7Q3M9R8XKZP4F8YQN2H",
"type": "document.usable",
"occurredAt": "2026-05-29T08:42:31.402Z",
"tenantId": "421fb813-f96e-4edc-8b88-95d2c76665a4",
"webhookId": "8a3e2c40-3f12-4f6d-902b-6e2f4ad9bb91",
"document": {
"id": "3b2c1a40-f128-4cb6-b8c2-19a4d8d7e2f5",
"type": "core/article",
"version": 3,
"status": "usable",
"title": "Anchored in the harbour"
}
}
The mode is set when the webhook is registered and can be flipped later from the webhook admin UI. Flipping the mode affects only future deliveries — pending retries keep the shape they were originally built with.
Embedded assets
When the snapshotted document carries inline image references
(base/image nodes with an imageId property — see
Media), the payload includes an embeddedAssets
array projecting each unique image:
{
"embeddedAssets": [
{
"id": "3a1d6a3e-...",
"readUrl": "/api/images/3a1d6a3e-.../original",
"contentHash": "9f86d081884c7d659...",
"originalFormat": "image/jpeg",
"dimensions": { "width": 4032, "height": 3024 },
"signature": {
"alg": "ed25519",
"keyId": "void-2026-05",
"sig": "<base64>"
},
"restrictions": true,
"aiGenerated": false,
"copyright": "(C) 2026 Jane Doe / Reuters",
"photographer": "Jane Doe",
"credit": "Reuters"
}
]
}
Notable
- The
idis identical to theimageIdproperty on the snapshot's inline image node — receivers can rewrite the inline node'sproperties.srcagainst thereadUrlhere. signaturecovers the original image bytes prefixed with"void-image-v1\0"— distinct from the snapshot's signature domain. Verification recipe in Signing.- The projection is live at delivery time, not frozen at
snapshot time. Image bytes are immutable (a re-edit is a new
image with a new id) but metadata (
copyright,restrictions, ...) can change after the article snapshot fires. Receivers that need an audit-time record should keep this projection on the delivery row; the snapshot bytes themselves only carry theimageIdreference. embeddedAssetsis absent on events with no snapshot (transitions into non-snapshotted statuses,webhook.test). An empty array means "snapshot walked, no images found"; a missing field means "no snapshot to walk".- A
imageIdreferenced in the snapshot but missing fromembeddedAssetsindicates the image was deleted between the article's snapshot and the webhook firing. Treat as a consciously-omitted asset.
Headers
Every delivery includes:
Content-Type: application/json
X-Void-Event-Id: 01J5K2V7Q3M9R8XKZP4F8YQN2H # stable across retries of the same event
X-Void-Delivery-Id: 01J5K2WA5GVR7N3KJZ4B8YH9XP # unique per attempt
X-Void-Event: document.usable
X-Void-Tenant-Id: 421fb813-f96e-4edc-8b88-95d2c76665a4
X-Void-Webhook-Id: 8a3e2c40-3f12-4f6d-902b-6e2f4ad9bb91
X-Void-Timestamp: 1716966151 # Unix seconds
X-Void-Signature: sha256=4f9d2a9c1e3b8f0d7a...
User-Agent: tidenda-webhook/1.0
X-Void-Event-Id is stable across all retries of the same
event. Two delivery attempts of the same event carry the same Event
Id; this is the dedup signal.
X-Void-Delivery-Id is unique per attempt. Useful for support
correlation ("delivery 01J…XYZ failed — can you check the log?").
Signature verification
The signature is computed as:
HMAC-SHA256(secret, `${X-Void-Timestamp}.${body}`)
Header value: sha256=<hex>.
Always verify the signature before processing the payload. The URL alone is not proof of authenticity — anyone who learns the URL can hit it. The signature proves the request came from someone with the webhook's secret, which the platform shows to the operator once at webhook creation.
Always check the timestamp is within 5 minutes of now and
reject otherwise. The timestamp inside the signed material is what
prevents replay of a captured request.
The full receiver-side recipe with sample code in Node, Python, and Go lives in Receiving webhooks. Working verifiers in all three languages are in verifier samples.
Delivery semantics
The platform guarantees at-least-once delivery. Combined with idempotent receiver logic, that yields effectively-exactly-once processing.
The receiver contract:
- On receipt: read
X-Void-Event-Id(oridin the body — same value). - Check your store for that event id.
- If present → return
200 OK. You've seen this event before; it's a retry. Do nothing else. - If absent → process the event, record the event id as
processed, then return
200 OK.
- If present → return
- Always return 2xx for events you've successfully processed (or already-processed events). The body of your response is captured (first 4 KB) and shown in the deliveries log for debugging; it doesn't affect delivery state.
- Return non-2xx, or fail to respond within 10 seconds, and the delivery is treated as failed and a retry is scheduled. See Retry policy below.
A minimal dedup table — event_id PRIMARY KEY, processed_at TIMESTAMPTZ — is enough. Keep entries for 30+ days to outlast the
retry window.
Retry policy
Failed deliveries (non-2xx, timeout > 10s, connection refused, TLS failure, DNS resolution failure) are retried with exponential backoff:
| Attempt | Delay after previous failure |
|---|---|
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 2 minutes |
| 5 | 5 minutes |
| 6 | 10 minutes |
| 7 | 20 minutes |
| 8 | 30 minutes |
| 9 | 60 minutes |
| 10 | 6 hours |
| 11 | 12 hours |
Total wall-clock budget across the 11 attempts: ~19 hours. The early intervals are short so a quick fix catches a retry inside the same hour; the later gaps widen so a long receiver outage doesn't burn the budget early.
After 11 failed attempts the delivery is marked permanently failed and no further automatic retries occur. The deliveries log shows the failure; an operator can use Replay to re-attempt manually when the receiver is back.
While a delivery is being retried, fresh events keep flowing.
Deliveries are not serialised per webhook or per tenant — two events
for the same webhook may arrive in either order, especially across
retry boundaries. Order events by occurredAt if order matters to
the receiver.
Configuring a webhook
In the application: Settings → Webhooks → New webhook. Requires
the system-admin role on the organisation.
Fill in:
- Name — a human label for your records (e.g. "Production CMS bridge").
- URL — HTTPS endpoint.
http://is rejected for non-localhost hostnames. - Document type — one type per webhook. To listen for two types, create two webhooks.
- Workflow statuses —
Any(fires on every transition for that type) or a selected subset.
On creation the full secret is displayed once in a modal — copy it to a secret store before closing. The platform keeps the secret (it has to, to sign each outbound delivery) but never exposes it through the API after creation. To rotate, delete the webhook and create a new one.
A new webhook is enabled by default. Disable from the list view to pause deliveries without losing the registration; re-enable to resume.
The deliveries log
Each webhook has a detail page listing events (one entry per
X-Void-Event-Id), not raw attempts. An event groups every attempt
made to deliver it — the initial fire plus any automatic retries or
manual replays — so a retried event shows up as a single row with an
attempt count and an expandable breakdown.
Each event row shows:
- Overall status —
success(some attempt returned 2xx),retrying(latest attempt failed, more attempts queued), orfailed(gave up after the max attempts). - Event type and the document it refers to (id + version).
- Number of attempts and time of the most recent one.
Expanding an event reveals the per-attempt breakdown, with each attempt's HTTP status, duration, error message, and the response body excerpt (first 4 KB) for failures.
From the log you can:
- Replay an event — re-send the exact same payload with the same Event Id (receiver-side dedup will catch it). Useful when an endpoint was down beyond the automatic retry window and you want to backfill rather than create a new event. Replay is hidden once an event has succeeded.
- Send test from the webhook detail header — fires a synthetic
event of type
webhook.testto verify connectivity and signature wiring. The test payload has"document": nulland"test": true; receivers should treat it as a no-op and just return 200.
The events list paginates by event (not by row), 20 events per page, with a Load older events button at the bottom.
Failure modes worth knowing
- The receiver URL changes — there's no redirect support. Update the URL on the webhook (or create a new one and delete the old).
- The receiver returns 200 too late — handlers that return 200 past the 10-second timeout are still treated as failures, so the delivery shows one failure plus one success for the same event. Receiver-side idempotency catches the retry.
- The receiver returns 410 Gone — currently treated as a retryable failure. Disable the webhook in the admin UI when the endpoint is being decommissioned.
Versioning
Payload shape and headers are stable. Additive changes (new fields,
new event types, new headers) are non-breaking and don't bump a
version. Breaking changes would arrive via a new event type prefix
(e.g. document.v2.usable) and be announced separately; current
events keep working.
Related pages
- Receiving webhooks — the receiver-side recipe.
- Signing and signatures — verifying the Ed25519 snapshot signature.
- API reference — the public HTTP surface, including the trust list endpoint.
- Workflows — what produces the transitions webhooks fire on.