Samples
Verifier samples
HMAC + Ed25519 verifiers across three runtimes.
Tidenda webhook verification samples
Three minimal, self-contained programs that show how to verify a Tidenda
webhook delivery. Each is a tiny HTTP server that accepts POST /webhook,
checks the HMAC-SHA256 request signature, and (when a snapshot is
attached) verifies the Ed25519 signature over the snapshot bytes.
No SDK. No framework you don't already have. Just raw verification logic you can lift into your own code.
The samples
| Language | File | Dependencies | How to run |
|---|---|---|---|
| Node.js (>=18) | verify-signature.js |
stdlib only (node:crypto, node:http) |
VOID_WEBHOOK_SECRET=... TIDENDA_SIGNING_KEY=... node verify-signature.js |
| Python 3.10+ | verify-signature.py |
cryptography (pip install cryptography) |
VOID_WEBHOOK_SECRET=... TIDENDA_SIGNING_KEY=... python verify-signature.py |
| Go 1.21+ | verify-signature.go |
stdlib only (crypto/hmac, crypto/ed25519, crypto/x509) |
VOID_WEBHOOK_SECRET=... TIDENDA_SIGNING_KEY=... go run verify-signature.go |
All three listen on http://localhost:8787/webhook by default (override
with PORT).
What each sample does
Receives
POST /webhookand reads the raw request body. Don't let your framework parse JSON first - the HMAC is computed over the bytes on the wire and any re-serialisation will change them.Reads
X-Void-Signature(sha256=<hex>) andX-Void-Timestamp.Computes
HMAC-SHA256(secret, "${timestamp}.${raw-body}")and compares it to the header value in constant time (crypto.timingSafeEqual/hmac.compare_digest/hmac.Equal). Rejects with401on mismatch, on missing header, or on a timestamp more than 5 minutes off the local clock.Parses the body as JSON.
If
document.snapshotBytes(base64) anddocument.snapshotSignatureare both present, verifies the Ed25519 signature over the byte sequence:b"void-snapshot-v1\x00" || decoded_snapshot_bytesThat is the 16-byte ASCII string
void-snapshot-v1, then a single0x00NUL byte, then the raw bytes you get from base64-decodingsnapshotBytes. The Ed25519 signature is computed over exactly that concatenation. Verify against the decoded bytes, not against a re-serialised JSON object - JSON has no canonical form.Returns
200on success,401on HMAC failure,400on snapshot signature mismatch or malformed JSON.
The spec is canonical: see docs/RECEIVING_WEBHOOKS.md, sections 2 and 4.
Note on the prompt vs the spec
If you've been told "the HMAC is over the request body": that's almost
right. The HMAC is over ${X-Void-Timestamp}.${raw-body} - the
timestamp is part of the signed material so receivers can reject stale
requests within a 5-minute window. All three samples follow the spec.
Layout of the payload
The webhook body is JSON. The interesting parts for signature verification:
{
"id": "01J0EVENT00000000000000000",
"type": "document.usable",
"occurredAt": "2026-06-15T10:00:00.000Z",
"tenantId": "00000000-0000-0000-0000-000000000000",
"webhookId": "00000000-0000-0000-0000-000000000000",
"document": {
"id": "doc_...",
"type": "core/article",
"version": 3,
"status": "usable",
"title": "Article headline as flat string",
"snapshotBytes": "<base64-encoded raw JSON bytes>",
"snapshotSignature": {
"alg": "ed25519",
"keyId": "void-2026-05",
"sig": "<base64 signature>"
}
}
}
document is null for webhook.test. snapshotBytes and
snapshotSignature are both null for events that don't produce a
snapshot (drafts, cancellations, custom statuses). The full schema lives
in RECEIVING_WEBHOOKS.md section 1.
Public key
snapshotSignature.keyId tells you which key was used. Fetch the
matching key from the platform's public endpoint:
GET <void-public-api>/api/keys
Unauthenticated. Response:
{
"active": "void-2026-05",
"keys": [
{
"keyId": "void-2026-05",
"algorithm": "ed25519",
"publicKey": "<base64 SPKI DER>",
"validFrom": "2026-05-29T00:00:00Z",
"validUntil": "2027-05-29T00:00:00Z"
}
]
}
Cache the result for around an hour - keys rotate rarely. Older keys
stay in the list (with validUntil set) so historical snapshots remain
verifiable after rotation. The publicKey is base64-encoded
SPKI DER; load it with crypto.createPublicKey (Node),
serialization.load_der_public_key (Python), or x509.ParsePKIXPublicKey
(Go).
In these samples the key is hardcoded via TIDENDA_SIGNING_KEY to keep
the focus on verification - they do not fetch /api/keys themselves.
Testing
The smallest end-to-end check uses a self-generated request. Pick a timestamp within 5 minutes of now, compute the HMAC, and POST. The Node sample in Receiving webhooks section 12 shows the exact one-liner.
Further reading
- Receiving webhooks - the canonical spec for receivers: headers, signature, retries, payload, snapshot signatures, idempotency, security checklist.