Node.js verifier
A complete webhook receiver with both signature checks, in Node.js.
Save as verify-signature.js. Setup and usage are on Verifier samples.
// Verifies a Tidenda webhook delivery end-to-end.
//
// 1. HMAC-SHA256 over `${X-Tidenda-Timestamp}.${raw-body}` keyed with the
// shared webhook secret. Header: `X-Tidenda-Signature: sha256=<hex>`.
// 2. (When `document.snapshotBytes` is present) Ed25519 over the byte
// sequence b"tidenda-snapshot-v1\0" || decoded-snapshot-bytes.
//
// Why two signatures? The HMAC proves "this HTTP request came from
// Tidenda". The Ed25519 signature proves "this content came from
// Tidenda and was not modified by any intermediary" - useful if you
// re-publish the snapshot downstream.
//
// Run:
// TIDENDA_WEBHOOK_SECRET=wh_... \
// TIDENDA_SIGNING_KEY=<base64-SPKI-DER> \
// node verify-signature.js
//
// Then POST something to http://localhost:8787/webhook.
import { createHmac, createPublicKey, timingSafeEqual, verify } from 'node:crypto'
import { createServer } from 'node:http'
// In real deployments, both of these come from a secret manager. They are
// hardcoded placeholders here so the sample focuses on verification.
const SECRET = process.env.TIDENDA_WEBHOOK_SECRET ?? 'wh_00000000_replace_me'
const SIGNING_KEY_B64 = process.env.TIDENDA_SIGNING_KEY ?? '' // base64 SPKI DER
// The Ed25519 domain-separation prefix. Note the trailing NUL byte - the
// Ed25519 message is exactly these 20 bytes followed by the decoded
// snapshot bytes. See docs/RECEIVING_WEBHOOKS.md §4.
const SNAPSHOT_DOMAIN = Buffer.from('tidenda-snapshot-v1\0', 'utf8')
// Tolerance window for X-Tidenda-Timestamp drift, in seconds. The sender
// re-stamps every retry, so this only catches genuinely stale traffic.
const MAX_CLOCK_SKEW_SECONDS = 300
const server = createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/webhook') {
res.statusCode = 404
return res.end()
}
// Collect the raw body. Verification MUST run against the bytes that actually
// came off the wire - re-serialising a parsed JSON object will mangle
// whitespace and unicode escapes, and the HMAC won't match.
const chunks = []
req.on('data', (c) => chunks.push(c))
req.on('end', () => {
const rawBody = Buffer.concat(chunks)
const signatureHeader = req.headers['x-tidenda-signature'] ?? ''
const timestampHeader = req.headers['x-tidenda-timestamp'] ?? ''
if (!verifyHmac(rawBody, String(timestampHeader), String(signatureHeader))) {
res.statusCode = 401
return res.end()
}
// HMAC is good - the bytes can now be trusted enough to JSON.parse them.
let payload
try {
payload = JSON.parse(rawBody.toString('utf8'))
} catch {
res.statusCode = 400
return res.end('invalid json')
}
// Optional: verify the snapshot signature if one is attached. Events
// like `webhook.test` or `document.draft` have null snapshots; nothing
// to verify in that case.
//
// On a mismatch, drop the event and alert, but still answer 2xx: any
// other status triggers a retry, and the retry carries the same
// snapshot bytes, which would fail the same way.
const doc = payload?.document
if (doc && doc.snapshotBytes && doc.snapshotSignature) {
if (!verifySnapshot(doc.snapshotBytes, doc.snapshotSignature)) {
console.error(`ALERT: snapshot signature mismatch, event ${payload.id} dropped`)
res.statusCode = 200
return res.end()
}
}
res.statusCode = 200
res.end()
})
})
function verifyHmac(rawBody, timestamp, header) {
// The header is `sha256=<lowercase hex>`. Anything else is invalid.
if (!header.startsWith('sha256=')) return false
const given = header.slice('sha256='.length)
// Replay protection: reject if the dispatch timestamp is too far from
// the local wall clock. The window is symmetric, so clock skew is
// tolerated in either direction.
const ts = Number.parseInt(timestamp, 10)
if (!Number.isFinite(ts)) return false
if (Math.abs(Date.now() / 1000 - ts) > MAX_CLOCK_SKEW_SECONDS) return false
// Signed material is exactly `${timestamp}.${raw-body}`. The body is
// appended as UTF-8 text - the platform sends application/json bodies,
// which are valid UTF-8 by definition.
const expected = createHmac('sha256', SECRET)
.update(`${ts}.${rawBody.toString('utf8')}`)
.digest('hex')
// timingSafeEqual requires equal-length buffers, so length-check first.
// Otherwise it throws and you'd leak that fact via timing.
if (expected.length !== given.length) return false
return timingSafeEqual(Buffer.from(expected), Buffer.from(given))
}
function verifySnapshot(snapshotBytesB64, signature) {
if (!SIGNING_KEY_B64) {
console.warn('TIDENDA_SIGNING_KEY not set - skipping snapshot signature check')
return true
}
if (signature.alg !== 'ed25519') return false
// Decode the snapshot to raw bytes. Verify against THESE bytes -
// not against `JSON.stringify(JSON.parse(bytes))`, which would not be
// byte-equal (JSON has no canonical form).
const snapshotBytes = Buffer.from(snapshotBytesB64, 'base64')
const sig = Buffer.from(signature.sig, 'base64')
// The signed message is the 20-byte domain prefix followed by the
// raw snapshot bytes. Domain separation prevents a signature minted
// for one purpose being replayed as another.
const message = Buffer.concat([SNAPSHOT_DOMAIN, snapshotBytes])
// Public keys are published as base64-encoded SPKI DER at GET /api/keys.
// In production you'd fetch /api/keys, cache for ~1h, and look the
// matching key up by signature.keyId. This sample uses a single hardcoded key.
const publicKey = createPublicKey({
key: Buffer.from(SIGNING_KEY_B64, 'base64'),
format: 'der',
type: 'spki'
})
// The first argument is the digest algorithm. Ed25519 has its own built-in
// hash (SHA-512 internally), so pass null - Node will route correctly.
return verify(null, message, publicKey, sig)
}
const port = Number(process.env.PORT ?? 8787)
server.listen(port, () => {
console.log(`Webhook receiver listening on http://localhost:${port}/webhook`)
})