Tidenda

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

  1. Receives POST /webhook and 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.

  2. Reads X-Void-Signature (sha256=<hex>) and X-Void-Timestamp.

  3. 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 with 401 on mismatch, on missing header, or on a timestamp more than 5 minutes off the local clock.

  4. Parses the body as JSON.

  5. If document.snapshotBytes (base64) and document.snapshotSignature are both present, verifies the Ed25519 signature over the byte sequence:

    b"void-snapshot-v1\x00" || decoded_snapshot_bytes
    

    That is the 16-byte ASCII string void-snapshot-v1, then a single 0x00 NUL byte, then the raw bytes you get from base64-decoding snapshotBytes. 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.

  6. Returns 200 on success, 401 on HMAC failure, 400 on 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.