Implementing a webhook receiver
Write a receiver: verify the HMAC, deduplicate, respond in time and test it.
On this page
A webhook receiver is an HTTPS endpoint that accepts deliveries from Tidenda, checks that each one is genuine, and acknowledges it quickly. This guide is for developers building one that holds up against forged, replayed and duplicate requests.
A complete receiver in Node.js with Express:
import crypto from 'node:crypto'
import express from 'express'
const SECRET = process.env.TIDENDA_WEBHOOK_SECRET // wh_...
const app = express()
// Event ids already handled. Kept in memory to keep the example short;
// use a persistent store in production so deduplication survives a restart.
const seenEventIds = new Set()
// Verification needs the raw body bytes, so don't parse JSON first.
app.post('/tidenda/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.header('X-Tidenda-Signature') ?? ''
const timestamp = req.header('X-Tidenda-Timestamp') ?? ''
const eventId = req.header('X-Tidenda-Event-Id') ?? ''
if (!verify(SECRET, timestamp, req.body, signature)) {
return res.status(401).end() // bad signature or stale timestamp
}
// Seen this event before? Acknowledge and stop.
if (seenEventIds.has(eventId)) return res.status(200).end()
seenEventIds.add(eventId)
// Acknowledge fast, work afterwards.
const event = JSON.parse(req.body.toString('utf8'))
setImmediate(() => handleEvent(event))
res.status(200).end()
})
function handleEvent(event) {
// Index, republish, notify. Use a job queue in production.
console.log('received', event.type, event.document?.id)
}
function verify(secret, timestamp, bodyBuf, header) {
if (!header.startsWith('sha256=')) return false
const ts = Number.parseInt(timestamp, 10)
if (!Number.isFinite(ts)) return false
if (Math.abs(Date.now() / 1000 - ts) > 300) return false // 5-minute window
const expected = crypto.createHmac('sha256', secret)
.update(`${ts}.${bodyBuf.toString('utf8')}`)
.digest('hex')
const given = header.slice('sha256='.length)
if (expected.length !== given.length) return false
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))
}
app.listen(8787)
Standalone receivers without a framework, in Node.js, Python and Go, are among the verifier samples. They also verify the snapshot signature, but leave deduplication to you.
Write the handler
Expose one route that accepts POST with a JSON body. The platform
calls the URL exactly as registered, with no query parameters added,
and never follows a redirect, so register the final address. The URL
must use https://; terminate TLS in the receiver or in a proxy in
front of it.
Read the body as raw bytes. The signature covers the bytes on the wire, and parsing then re-serialising JSON changes whitespace and escapes, which breaks verification:
- Express:
express.raw({ type: 'application/json' })on the route. - Fastify:
addContentTypeParserwith{ parseAs: 'buffer' }. - FastAPI:
await request.body()before any model parsing.
Verify the signature
Every delivery carries X-Tidenda-Timestamp (Unix seconds) and
X-Tidenda-Signature (sha256= followed by a lowercase hex digest).
Compute:
HMAC-SHA256(secret, `${X-Tidenda-Timestamp}.${raw body}`)
The key is the whole secret, wh_ prefix included, as shown once
when the webhook was created. Keep it in a secret store, never in
source code or logs.
Then:
- Compare in constant time. Use
crypto.timingSafeEqualin Node.js,hmac.compare_digestin Python orhmac.Equalin Go. String equality leaks the digest through timing. Web Crypto has no compare function; import the secret as an HMAC key and letcrypto.subtle.verifycheck the hex-decoded digest. - Reject stale timestamps. If the timestamp is more than 300 seconds from your clock in either direction, reject the request. The timestamp is part of the signed material, so a captured request cannot be replayed later with a fresh one.
- Answer failures with
401and no body. Log the delivery id (X-Tidenda-Delivery-Id), the source address and the reason, but never the secret or the signature header.
Only after verification succeeds may the body be parsed and trusted.
Deduplicate
Delivery is at least once, so the same event can arrive more than
once: through automatic retries, through a manual replay, or because
your 200 was lost on the way back. All copies carry the same
X-Tidenda-Event-Id, also present in the body as id. Nothing marks
a replay as different from a retry, and nothing needs to.
Treat the event id as an opaque string. In production, keep it in a table with a unique key:
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- INSERT ... ON CONFLICT (event_id) DO NOTHING; zero rows inserted = duplicate
Keep entries for at least 30 days, well beyond the roughly 20 hours of automatic retries, so a late replay is still recognised. A cache with a time-to-live works for side effects that are cheap to repeat.
Record the id in the same step that queues the work. Recording it before the work is safely queued loses the event if the process dies in between.
Respond within the timeout
A delivery succeeds when the receiver answers with any 2xx status within 10 seconds, response body included. Every other outcome counts as a failed attempt and is retried: a 3xx, 4xx or 5xx status, a timeout, or a connection or TLS error.
- Acknowledge first, work later. Verify, deduplicate, enqueue a
job, return
200. Indexing, rendering and forwarding belong in the background job. - Return 2xx for duplicates. A duplicate was handled the first time; a non-2xx answer only causes more retries.
- Return 2xx for events you ignore, such as
webhook.testor an unexpected event type. - Keep the response short. The first 4,096 characters are shown in the deliveries log, which makes a one-line reason useful when something fails.
Read the content
document.snapshotBytes is the signed snapshot, base64-encoded:
const bytes = Buffer.from(event.document.snapshotBytes, 'base64')
const snapshot = JSON.parse(bytes.toString('utf8'))
It is null for statuses that produce no snapshot and absent when
the webhook uses the compact payload mode. Its shape depends on the
document type and is described in Document formats;
titles and bodies are rich-text node trees, not strings, as described
in Rich text. For a plain title,
document.title holds a flat string when one is available.
If the content is forwarded or archived, verify snapshotSignature
against the decoded bytes before using them. See Verifying
signatures.
Unknown fields and event types will appear over time. Ignore fields
you don't recognise, and match event types by prefix, such as
document., rather than by a fixed list.
Fetch the media
The snapshot names images and audio files by id. In full mode the
event also lists them in embeddedAssets and embeddedAudioAssets,
each with an absolute readUrl on the public API. Fetch it with an
API key of the same organisation that has the publisher:read scope,
and follow the redirect to storage:
async function download(asset, apiKey) {
const res = await fetch(asset.readUrl, {
headers: { Authorization: `Bearer ${apiKey}` }
})
if (!res.ok) throw new Error(`media ${asset.id}: ${res.status}`)
const bytes = Buffer.from(await res.arrayBuffer())
const hash = crypto.createHash('sha256').update(bytes).digest('hex')
if (asset.contentHash && hash !== asset.contentHash) {
throw new Error(`media ${asset.id}: content hash mismatch`)
}
return bytes
}
fetch follows the redirect and drops the Authorization header when
the host changes, as the storage link requires. Do the download in
the background queue, not inside the webhook request, and keep the
bytes: the file is readable only while an article in status usable
or withheld uses it, and the storage link expires within 10 minutes.
Before republishing a file, verify its signature as described in
Verifying signatures. Variants and errors are in the
API reference.
Test the receiver
Send test on the webhook's page sends a signed webhook.test
event with "document": null and "test": true. See Setting up
webhooks.
To test without the platform, sign a request yourself:
const body = JSON.stringify({
id: 'test-0001',
type: 'webhook.test',
occurredAt: new Date().toISOString(),
tenantId: '00000000-0000-0000-0000-000000000000',
webhookId: '00000000-0000-0000-0000-000000000000',
document: null,
test: true
})
const ts = Math.floor(Date.now() / 1000)
const sig = crypto.createHmac('sha256', SECRET).update(`${ts}.${body}`).digest('hex')
// POST body with X-Tidenda-Timestamp: ts and X-Tidenda-Signature: sha256=<sig>
Cases worth covering:
- Valid signature and fresh timestamp:
200. - One character of the body changed:
401. - Timestamp six minutes old:
401. - Signature header missing:
401. - The same event id twice:
200both times, one side effect. - Handler throws: non-2xx, and a retry follows.
- Snapshot bytes altered and re-encoded: snapshot verification fails, an alert fires, nothing is forwarded.
Common failures
- Every delivery fails with 401. The body was parsed before verification, or the receiver holds another webhook's secret.
- Duplicates are processed twice. Deduplication runs after the side effect, or the store forgets ids before replays arrive.
- Timeouts under load. Work runs inside the request; move it to a queue.
- Snapshot signature mismatch. Usually verification against
re-serialised JSON or without the domain prefix. Drop and alert,
but answer
200; a retry carries the same bytes and fails the same way.
Checklist
- Body read as raw bytes and verified before anything else.
- HMAC compared in constant time, with a 300-second timestamp window.
- Secret held in a secret store; never logged.
- HTTPS only.
- Deduplication on
X-Tidenda-Event-Id, kept for 30 days or more. - 2xx within 10 seconds; slow work in a background queue.
- Unknown fields and event types ignored, not rejected.
- Snapshot signatures verified before content is forwarded.
- Media downloaded in the background with a
publisher:readkey, checked againstcontentHash, and kept.