Notification webhooks

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

HeaderFormatDescription
X-Tekmerion-Signaturev1=<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}
  • v1 is the literal version token.
  • {timestamp} is the exact decimal integer from X-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) > 300

Perform 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.

LanguageConstant-time function
Pythonhmac.compare_digest(a, b)
Node.jscrypto.timingSafeEqual(a, b)
Gosubtle.ConstantTimeCompare(a, b)
RubyActiveSupport::SecurityUtils.secure_compare(a, b)
PHPhash_equals($a, $b)
JavaMessageDigest.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.

On this page