Tidenda

Webhook reference

Every header, payload field, event type, retry step and embedded media field.

On this page

The exact shape of a webhook delivery: request, headers, payload, event types, embedded media, retries and versioning. For developers who have a receiver running and need to look something up.

Request

Property Value
Method POST
URL Exactly as registered. No query parameters are added.
Scheme https://. http:// only for localhost and 127.0.0.1.
Body A JSON object, described under Payload below.
Redirects Never followed. A 3xx answer is a failed attempt.
Timeout 10 seconds for the whole exchange, response body included.

Headers and signature

Every delivery sets all of these:

Header Value
Content-Type application/json
User-Agent tidenda-webhook/1.0
X-Tidenda-Event-Id The event id. Opaque string. Same across retries and replays of one event; the deduplication key.
X-Tidenda-Delivery-Id This attempt's id, 26 characters. Different on every attempt.
X-Tidenda-Event The event type, equal to type in the body.
X-Tidenda-Tenant-Id The organisation's id, equal to tenantId in the body.
X-Tidenda-Webhook-Id The webhook's id, equal to webhookId in the body.
X-Tidenda-Timestamp Unix seconds when this attempt was sent. Different on every attempt.
X-Tidenda-Signature sha256= followed by the lowercase hex HMAC described below. Different on every attempt.

There is no version header. Every webhook that matches the same document event receives the same event id.

The signature is:

hex(HMAC-SHA256(secret, `${X-Tidenda-Timestamp}.${raw body}`))

secret is the webhook's full secret, wh_<8 hex characters>_<43 base64url characters>, 55 characters in all. Receivers reject a timestamp more than 300 seconds from their own clock. The recipe is in Implementing a webhook receiver.

Payload

interface WebhookPayload {
  id: string                 // = X-Tidenda-Event-Id
  type: string               // event type, see below
  occurredAt: string         // ISO 8601, when the event was built
  tenantId: string           // organisation id
  webhookId: string
  document: {
    id: string
    type: string             // e.g. 'core/article'
    version: number          // non-negative integer
    status: string           // the status the document is in
    title: string | null     // flat-text title, when available
    snapshotBytes?: string | null
    snapshotSignature?: { alg: 'ed25519', keyId: string, sig: string } | null
  } | null                   // null for webhook.test
  embeddedAssets?: EmbeddedAsset[]
  embeddedAudioAssets?: EmbeddedAudioAsset[]
  embeddedAudioQuotes?: EmbeddedAudioQuote[]
  test?: true                // present only on webhook.test
}
Field Notes
occurredAt Set when the body is built. Retries resend the same value; a replay carries a new one.
document.version The document's meta.version.
document.title A plain-text copy of the title. Inside the snapshot, title is a rich-text tree. null on replays.
document.snapshotBytes Base64 of the snapshot JSON, for usable and withheld. null for other statuses. Absent in compact mode.
document.snapshotSignature Ed25519 over "tidenda-snapshot-v1\0" followed by the decoded bytes. null when there is no snapshot or the snapshot was stored unsigned. Absent in compact mode.
embedded* arrays Present, possibly empty, when the event carries a snapshot. Absent when it does not, and in compact mode.

The snapshot's own shape is described in Document formats. Signature verification is in Verifying signatures.

AI disclosure in the snapshot

The snapshot's meta carries two fields about AI, both covered by the signature:

Field Type Notes
meta.aiGenerated boolean, optional The reader AI label. true means readers are to be told AI helped produce the document; absent or false means no label.
meta.publishedWithoutReview { at: string, operationRef: string }, optional Present when an AI workflow step published this version with no person reviewing the AI's answer. at is when, operationRef the operation's reference name. When present, aiGenerated is true. Absent on versions a member published.

A receiver that republishes the document should show a disclosure when aiGenerated is true, and say the text was published without editorial review when publishedWithoutReview is present. The AI record behind these fields is private and never delivered. See Publishing without review.

Event types

Type Fires when a document of the subscribed type is saved in snapshotBytes
document.draft draft; on every save null
document.usable usable (published) populated
document.withheld withheld (embargoed) populated
document.cancelled cancelled (unpublished) null
document.<custom> A custom workflow status, matching [a-z0-9_-]+; on every save null
webhook.test Never; sent by Send test. document is null. not present

Document deletions fire no event.

Embedded media

Full-mode events with a snapshot carry three arrays describing the media the snapshot references. Each image or audio file appears once, however often it is referenced. The metadata is read when the event is built, so it reflects the media as it was at delivery, not at publication; retries resend the same values. A media id referenced in the snapshot but missing from the array belongs to an item that was deleted.

readUrl is an absolute URL on the public API, https://<public-api-host>/v1/media/images/<id>/original or https://<public-api-host>/v1/media/audio/<id>/original. Call it with an API key of the same organisation that has the publisher:read scope; it answers with a redirect to a storage link that expires within 10 minutes. The file stays readable while an article in status usable or withheld uses it, so download it when the event arrives. Replace original with another variant name to fetch a derived version. Without these arrays, in compact mode for example, form the same URL from the public API host and the imageId or audioId in the snapshot body. The endpoint, its variants and its errors are in the API reference, under GET /v1/media.

embeddedAssets

Images, from base/image nodes with an imageId property.

Field Type Notes
id string The core/image id, equal to the node's imageId.
readUrl string https://<public-api-host>/v1/media/images/<id>/original
contentHash string | null Lowercase hex SHA-256 of the original bytes.
originalFormat string | null MIME type of the original upload.
dimensions { width, height } | null Pixel size of the original.
signature { alg, keyId, sig } | null Ed25519 over "tidenda-image-v1\0" and the original bytes. null if the image was stored unsigned.
restrictions boolean true when usage terms or instructions are set, aiGenerated is true, a usage scope is forbidden, or the usage has expired, at the time of delivery.
aiGenerated boolean | null null means unknown.
copyright string | null
photographer string | null
credit string | null

embeddedAudioAssets

Audio files, from base/audio-embed blocks and audio quotes with an audioId property. A file used by both appears once.

Field Type Notes
id string The core/audio id.
readUrl string https://<public-api-host>/v1/media/audio/<id>/original
contentHash string | null Lowercase hex SHA-256 of the original bytes.
originalFormat string | null MIME type of the original upload.
durationMs integer | null Length of the recording.
languages string[] | null Languages found in the transcript. null while processing; [] when processing found none.
signature { alg, keyId, sig } | null Ed25519 over "tidenda-audio-v1\0" and the original bytes. null if the file was stored unsigned.
restrictions boolean As for images.
aiGenerated boolean | null null means unknown.
copyright string | null
recordist string | null The person who made the recording.
credit string | null

Transcript segment signatures are not included; they live on the core/audio document.

embeddedAudioQuotes

One entry per base/blockquote block lifted from an audio transcript, in document order. Quotes typed by hand carry no audio reference and are not listed.

Field Type Notes
audioId string The source audio; join with embeddedAudioAssets[].id.
audioContentHash string | null The source's contentHash when the quote was inserted. A difference from the current contentHash means the audio was replaced.
text string The quote's plain text as it appears in the document. Editors may tidy it.
startMs, endMs integer The quoted range on the source recording; the authority on what was said.
speakerId string | null Speaker at insert time.
speakerLabel string | null Speaker name at insert time. Later renames do not change it.
segmentIds string[] Transcript segments the quote spans. Can be empty.
originalTextHash string | null SHA-256 of the text at insert time. A mismatch with SHA-256 of text means the quote was edited.

Delivery, timeout and retries

Outcome Result
2xx within 10 seconds Delivered. No more attempts.
3xx, 4xx or 5xx Failed attempt.
Timeout, connection, DNS or TLS failure Failed attempt.

A failed attempt is retried after at least:

Attempt Delay after the 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

Eleven attempts in all, the last about 20 hours after the first. After that the event is marked failed. A retry resends the original body with a new delivery id, timestamp and signature. It is dropped if, when it falls due, the webhook is disabled, revoked or no longer subscribed to the event's status.

An event whose snapshot exceeds 2 MiB (2,097,152 bytes of JSON), or whose snapshot cannot be found, fails permanently without an HTTP attempt and without retries. A usable or withheld event is only emitted once its snapshot is stored, so a missing snapshot means the snapshot could not be written or was lost from storage.

Replays and test events

A replay, started from the deliveries log, sends an event again with the same event id. The body is rebuilt in the webhook's payload mode, by the same rules as a first delivery: in full mode the snapshot is read again and the embedded arrays are rebuilt, in compact mode the body carries the envelope only. occurredAt is new and document.title is null. It is a single attempt.

A test is a webhook.test event with a new event id each time, "document": null and "test": true. It is a single attempt.

Versioning

Payloads carry no version number. Additive changes are not breaking and can happen at any time: new body fields, new headers, new event types. An incompatible change would ship under a new event type prefix, such as document.v2.usable, with the existing types unchanged. Receivers ignore fields they do not recognise.