Signature verification
How to verify notification signatures.
Every notification webhook carries two authentication headers. The receiving endpoint MUST verify both before processing the payload. Tekmerion MUST NOT dispatch a notification without both headers, and MUST NOT send an unsigned notification.
Authentication headers
| Header | Format | Description |
|---|---|---|
X-Tekmerion-Signature | v1=<hex> | HMAC-SHA256 digest with version prefix. v1 is the current version token. <hex> is 64 lowercase hexadecimal characters. No whitespace inside the value; the = delimiter MUST NOT be URL-encoded. |
X-Tekmerion-Timestamp | <unix_epoch_seconds> | Unix seconds (UTC) at which Tekmerion executed the request. Decimal integer, no leading zeros, no fractional seconds. |
Algorithm
- Algorithm: HMAC-SHA256.
- Key: the raw bytes of the active webhook signing secret for the receiving endpoint.
- Message: the signature base string defined below.
- Digest encoding: lowercase hexadecimal, 64 characters. Uppercase hex and Base64 MUST NOT be used.
Base string
The signature base string is the UTF-8 encoding of three components joined by a literal colon (:), with no added whitespace or newlines:
v1:{timestamp}:{raw_body}v1is the literal version token.{timestamp}is the exact decimal integer fromX-Tekmerion-Timestamp— used verbatim, never re-derived.{raw_body}is the exact raw UTF-8 bytes of the request body as received. If the body is empty, this component is the empty string and the base string ends in a trailing colon.
The signature binds to the exact bytes on the wire. Verify against the raw body as received — not a re-parsed, re-serialized, key-reordered, or whitespace-normalized form. A proxy that rewrites the JSON will invalidate the signature.
Verification procedure
Verify in this order:
Step 1 — Extract headers. Read X-Tekmerion-Signature and X-Tekmerion-Timestamp. If either is absent, reject the request as unsigned (HTTP 400). Do not attempt partial verification.
Step 2 — Parse version token. Split X-Tekmerion-Signature on the first =. The left part is the version token; the right part is the digest. If the token is not v1, reject as an unsupported version.
Step 3 — Replay-window check. Compare X-Tekmerion-Timestamp against current time and reject if:
abs(now_unix_seconds - X-Tekmerion-Timestamp) > 300Perform this check before computing the HMAC, to avoid spending compute on stale or replayed requests. Reject timestamps in the future beyond reasonable clock-skew tolerance.
Step 4 — Reconstruct the base string. Build v1:{timestamp}:{raw_body} per the rules above.
Step 5 — Compute the expected digest. Compute HMAC-SHA256 over the UTF-8 bytes of the base string using the signing secret configured for the receiving endpoint. Encode as lowercase hex.
Step 6 — Constant-time compare. Compare the expected digest against the digest from Step 2 using a constant-time function. If they differ, reject.
Step 7 — Process. Only after all preceding steps pass, parse and process the JSON payload.
Constant-time comparison
Comparison of the received and computed digests MUST use a constant-time function. Standard string equality (==, equals(), strcmp(), or equivalent) MUST NOT be used — it is vulnerable to timing side-channel attacks that recover a valid signature byte by byte.
A valid digest is always 64 hexadecimal characters. If the received digest length is not 64, reject immediately before comparison.
| Language | Constant-time function |
|---|---|
| Python | hmac.compare_digest(a, b) |
| Node.js | crypto.timingSafeEqual(a, b) |
| Go | subtle.ConstantTimeCompare(a, b) |
| Ruby | ActiveSupport::SecurityUtils.secure_compare(a, b) |
| PHP | hash_equals($a, $b) |
| Java | MessageDigest.isEqual(a, b) |
Worked example
Given:
X-Tekmerion-Timestamp:1714000000- Raw body:
{"delivery_record_id":"dr_01","payment_intent_id":"pi_01","merchant_id":"m_01","notification_class":"payment_finalized","attempt_id":null,"chain_id":null,"finality_outcome":"paid","hold_reason":null}
Base string (single line, exactly as constructed):
v1:1714000000:{"delivery_record_id":"dr_01","payment_intent_id":"pi_01","merchant_id":"m_01","notification_class":"payment_finalized","attempt_id":null,"chain_id":null,"finality_outcome":"paid","hold_reason":null}HMAC-SHA256 is computed over the UTF-8 encoding of that string using the endpoint signing secret as key. The result is rendered as v1=<hex> in X-Tekmerion-Signature.
Secret resolution
The signing secret is resolved at execution time. After a secret is regenerated, every newly executed notification — including retries and manual re-deliveries — is signed with the new secret. Verify against the secret currently active for the endpoint, and do not cache a superseded secret.
Notification signing secrets are scoped per notification endpoint and are not shared with the KYT request surface, whose headers use the X-Tekmerion-KYT- prefix.