Tidenda

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. Inline snapshotBytes + snapshotSignature, plus the embeddedAssets projection. Zero-fetch delivery. Chosen automatically for new subscribers.
  • compact — omits snapshotBytes, snapshotSignature and embeddedAssets entirely. The delivery is only the transition envelope. Subscribers that want snapshot content fetch it themselves — typically by pairing the webhook with a publisher:read API key against GET /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 id is identical to the imageId property on the snapshot's inline image node — receivers can rewrite the inline node's properties.src against the readUrl here.
  • signature covers 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 the imageId reference.
  • embeddedAssets is 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 imageId referenced in the snapshot but missing from embeddedAssets indicates 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:

  1. On receipt: read X-Void-Event-Id (or id in the body — same value).
  2. 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.
  3. 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.
  4. 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 statusesAny (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), or failed (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.test to verify connectivity and signature wiring. The test payload has "document": null and "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