Tidenda

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: addContentTypeParser with { 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:

  1. Compare in constant time. Use crypto.timingSafeEqual in Node.js, hmac.compare_digest in Python or hmac.Equal in Go. String equality leaks the digest through timing. Web Crypto has no compare function; import the secret as an HMAC key and let crypto.subtle.verify check the hex-decoded digest.
  2. 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.
  3. Answer failures with 401 and 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.test or 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: 200 both 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:read key, checked against contentHash, and kept.