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.