Verifying Webhook Signatures

Every Webhook TabaPay delivers to your endpoint can carry an HMAC signature, so you can prove a request genuinely came from TabaPay rather than from anyone else who can reach your URL. You hold a shared secret, TabaPay signs each delivery with it, and you recompute the signature to check it.

You control the secret's lifecycle: you ask for it, and you rotate it whenever you want. TabaPay keeps your previous secret valid for 48 hours after a rotation so you can roll over without dropping events.

Quick Start

  1. Call POST /v2/clients/{ClientID}/webhooks/secrets with a body of {} and store the returned secret field. The secret is disclosed once and never shown again.
  2. On each delivery, read the X-HMAC-Signature header.
  3. Compute HMAC-SHA256 over <timestamp>.<raw body> using your secret as the key, and hex-encode it.
  4. Compare it to the v1 value in the header. If it matches, the delivery is genuine.

1. Getting your Signing Secret

POST /v2/clients/{ClientID}/webhooks/secrets

You must send a request body. An empty JSON object, {}, is enough. A POST with no body at all is rejected with 406 Not Acceptable before it reaches the API, which looks nothing like an application error and is the most common first-call mistake.

Request

No fields in the body are read.

curl -X POST "https://{FQDN}:{PORT}/v2/clients/{ClientID}/webhooks/secrets" \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{}'

Response

For more response field descriptions, refer to Create Webook Secret.

{
  "SC": 200,
  "EC": "0",
  "secret": "l-PdbjV5K-6K_LyuZQSN5Q0zMuY55EWN823Q0D4qOk8",
  "algorithm": "HMAC-SHA256"
}

The secret shown in the example is the public test vector from section 6, not a live value:

The secret is disclosed exactly once. There is no endpoint that returns it later. Store it in your secret manager the moment you receive it. If you lose it, your only route back is to rotate, which issues a new one.

  • secret: 32 random bytes encoded as unpadded base64url, so 43 characters from the set A-Za-z0-9-_. There is no prefix.
  • algorithm: Cryptographic algorithm used to generate the signature.

    Read algorithm from the response rather than hard-coding it, so that a future algorithm change does not require a code change on your side.

Client-level versus subclient-level secrets

PathScope
/v2/clients/{ClientID}/webhooks/secretsClient-level. Used for any subclient that has no secret of its own.
/v2/clients/{ClientID}_{SubClientID}/webhooks/secretsThat one subclient only. Overrides the client-level secret for its own events.

Note: Only create SubClient-scoped secrets if you need different SubClients to verify against different keys.

Errors

HTTPECEMWhat it means
40931818000secretA secret already exists. Use rotate instead — creating never silently replaces a live secret.
40031817000subClientIDNo configuration exists for this client/subclient pair. The subclient is not boarded yet.
40031811000clientIDClient ID missing or zero.
40031812000subClientIDNegative subclient ID.
40013364000subClientIDThe value after the underscore in the path is not a valid non-negative integer.
40513365000Method other than POST or PUT. There is no GET or DELETE on this resource.
50031810000A failure on the TabaPay side. Safe to retry.

2. Rotating the secret

PUT /v2/clients/{ClientID}/webhooks/secrets

Same body rule and same scoping as create.

Response

For response field descriptions, refer to Rotate Webook Secret.

{
  "SC": 200,
  "EC": "0",
  "secret": "R0t4t3d-EXAMPLE-ONLY-not-a-real-secret-val1",
  "algorithm": "HMAC-SHA256",
  "rotated": true
}

A rotation promotes the new secret and keeps the outgoing one valid for 48 hours. During that window every delivery is signed twice — once with each secret — so you can deploy the new secret at your own pace without losing events.

Read the rotated flag. If you call rotate while a previous rotation's 48-hour window is still open, nothing rotates.

You get 200 with "rotated": false and the current secret is disclosed again. This is deliberate: if you lost the response to your last call, you can recover the secret without burning a second rotation on your clients. It also means polling this endpoint will not keep giving you new secrets, and the window is not extended by the extra call.

Errors

HTTPECEMWhat it means
40931919000secretNo secret exists yet. Create one first.
40031918000subClientIDNo configuration for this client/subclient pair.
40031911000clientIDClient ID missing or zero.
40031912000subClientIDNegative subclient ID.
50031910000A failure on the TabaPay side. Safe to retry.

3. The signature on a delivered event

Signed deliveries carry these headers:

HeaderPurpose
X-HMAC-SignatureThe timestamp and one or two digests. Described below.
Idempotency-KeyStable per event across retries. Use it to deduplicate.
Content-TypeAlways application/json.

Header format

X-HMAC-Signature: t=1786000000,v1=77308cd2c7c660a1830f754fa41c8e8302e4897b093c44a4fcdd8685247719a6

And during a rotation overlap:

X-HMAC-Signature: t=1786000000,v1=<digest under the current secret>,v2=<digest under the previous secret>
PartMeaning
tUnix time in seconds, decimal.
v1Digest keyed by the current secret. Always present.
v2Digest keyed by the previous secret. Present only while a rotation overlap is open. Either digest verifying is a pass.

Parse by splitting on commas and then on the first =. Do not assume a fixed order or a fixed number of parts — treat unknown labels as ignorable so a future v3 does not break you.

What is signed

base   = <t> + "." + <exact raw response body bytes>
digest = lowercase_hex( HMAC-SHA256( key = your secret, data = base ) )

The t=, v1= and v2= labels are part of the header value but are not covered by the signature. Only the timestamp, the separating dot, and the body bytes are.

Use the secret string exactly as issued. The HMAC key is the 43-character text we gave you, as UTF-8 bytes. Do not base64-decode it first. Providers differ on this point, so if you have integrated signed webhooks elsewhere your instinct may be wrong here. A decoded key produces a digest that never matches, and nothing in the failure tells you why. There is a worked negative control for exactly this mistake in section 6.

Sign the raw bytes, before parsing. Capture the body as received. If you parse the JSON and re-serialise it, key order and whitespace change, the bytes change, and the digest will not match even though your code looks correct. In Express use express.raw(); in Flask use request.get_data(); in Go read r.Body before decoding. There is no trailing newline on the body.

4. Verifying a delivery

The steps in order:

  1. Read the raw body bytes and the X-HMAC-Signature header.
  2. Pull t, v1, and v2 if present.
  3. Reject the delivery if t is outside a freshness window you choose. See section 5.
  4. Build t + "." + rawBody.
  5. Compute the HMAC with your current secret and compare, in constant time, to v1.
  6. If that fails and v2 is present and you still hold the previous secret, compare against v2 too.
  7. If neither matches, treat the delivery as unverified. See section 5 for how to respond.

Example Script

See the following example script for a Node.js and Python example.

const crypto = require('crypto');

// rawBody must be a Buffer of the bytes as received.
// Express: app.use(express.raw({ type: 'application/json' }));
function verifyTabaPayWebhook(rawBody, signatureHeader, currentSecret, previousSecret) {
  const parts = {};
  for (const piece of String(signatureHeader).split(',')) {
    const i = piece.indexOf('=');
    if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
  }

  const t = parts.t;
  if (!t || !/^[0-9]+$/.test(t)) return false;

  // Freshness window is yours to choose; TabaPay does not enforce one.
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (skew > 300) return false;

  const base = Buffer.concat([Buffer.from(t + '.', 'utf8'), rawBody]);

  const matches = (given, secret) => {
    if (!given || !secret) return false;
    const expected = crypto.createHmac('sha256', secret).update(base).digest('hex');
    const a = Buffer.from(given, 'utf8');
    const b = Buffer.from(expected, 'utf8');
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  };

  return matches(parts.v1, currentSecret) || matches(parts.v2, previousSecret);
}
import hmac
import hashlib
import time


def verify_tabapay_webhook(raw_body: bytes, signature_header: str,
                           current_secret: str, previous_secret: str = "") -> bool:
    parts = {}
    for piece in signature_header.split(","):
        if "=" in piece:
            label, value = piece.split("=", 1)
            parts[label.strip()] = value.strip()

    t = parts.get("t", "")
    if not t.isdigit():
        return False

    # Freshness window is yours to choose; TabaPay does not enforce one.
    if abs(int(time.time()) - int(t)) > 300:
        return False

    base = t.encode() + b"." + raw_body

    def matches(given: str, secret: str) -> bool:
        if not given or not secret:
            return False
        expected = hmac.new(secret.encode(), base, hashlib.sha256).hexdigest()
        return hmac.compare_digest(given, expected)

    return matches(parts.get("v1", ""), current_secret) or \
        matches(parts.get("v2", ""), previous_secret)

5. Responding to a delivery

If a signature fails to verify, respond with a 5xx status, not a 4xx. A 4xx is treated as a permanent rejection and the event is dropped — it will not be retried. A 5xx is treated as transient and the event is retried, which is what you want while you investigate. Returning 400 on a signature mismatch is the difference between a delayed event and a lost one.

Freshness and replay

TabaPay does not reject stale timestamps, so the replay window is entirely yours to define and enforce. A few minutes is typical; pick a value that tolerates clock skew between your servers and ours, and reject anything outside it.

Two related details:

  • Every retry attempt is signed afresh with a new timestamp and therefore a new digest. A signature is never reused, so do not treat a repeated signature as a duplicate, and do not cache verification results by signature. Deduplicate on Idempotency-Key instead.
  • Retries can span many hours. Because each attempt carries its own timestamp, a retry arriving long after the original event still passes a tight freshness check.

6. Test vector

Use this to validate your implementation before pointing it at live traffic. These values are fixed and have been verified end to end.

InputValue
Secretl-PdbjV5K-6K_LyuZQSN5Q0zMuY55EWN823Q0D4qOk8 (43 characters)
Timestamp1786000000
Body160 bytes, shown below, with no trailing newline

Body, exactly:

{"iso":"1234","mid":"9876543210","transactionID":"7031180000523931111","referenceID":"REF-2026-000123","amount":"25.00","updatedStatus":"COMPLETED","code":"00"}

The same body as hex, so you can rule out any doubt about the exact bytes if you retyped or reformatted the JSON above:

7b2269736f223a2231323334222c226d6964223a2239383736353433323130222c227472616e73616374696f6e4944223a2237303331313830303030353233393331313131222c227265666572656e63654944223a225245462d323032362d303030313233222c22616d6f756e74223a2232352e3030222c2275706461746564537461747573223a22434f4d504c45544544222c22636f6465223a223030227d

Expected result:

digest  77308cd2c7c660a1830f754fa41c8e8302e4897b093c44a4fcdd8685247719a6
header  t=1786000000,v1=77308cd2c7c660a1830f754fa41c8e8302e4897b093c44a4fcdd8685247719a6

Negative controls

These are the two most common implementation mistakes. If your code produces one of these digests instead of the one above, this table tells you exactly what you did wrong — no support ticket required.

If you get this digestYou made this mistakeFix
3f4c4cfd090395a0ff8c2b164d333d64a0587d60ab5e8a3719a95664ab57f278You base64-decoded the secret and used the resulting bytes as the key.Use the 43-character string as-is, as UTF-8 bytes.
1e813f392153664c3d223ec5de606d8994e4e2b9b63486b0398fa0f26a1a0675Your body had a trailing newline appended.Sign the exact bytes received, with nothing added.

7. Before you enroll, and while you migrate

If no secret exists for a client/subclient pair, its events are delivered unsigned rather than held back. Signing is not a delivery gate, so creating a secret does not put your event flow at risk, and failing to create one does not stop events arriving.

Once a secret does exist, every event delivered for that pair is signed. There is no per-event-type opt-in.

A safe way to adopt this on a live integration:

  1. Create the secret and store it, but keep verification in log-only mode at first — compute the digest, record whether it matched, and keep accepting the delivery either way.
  2. Once you see a clean match rate, switch to enforcing, remembering to fail with a 5xx.
  3. To rotate later: call rotate, deploy the new secret as your current one and keep the old as previous, then drop the old one after the 48-hour window closes.

8. Troubleshooting

SymptomLikely cause
406 Not Acceptable when creating a secretYou sent no request body. Send {}.
409 with EC 31818000A secret already exists for this scope. Rotate, or use the one you stored.
409 with EC 31919000You called rotate before ever creating a secret.
Rotate returned "rotated": falseExpected. A previous rotation's 48-hour window is still open, so the standing secret was re-disclosed.
Every signature failsAlmost always one of: the key was base64-decoded, or the body was re-serialised before signing. Check both negative controls in section 6.
Signatures fail only for some subclientsThose subclients may have their own subclient-scoped secret that you are not verifying against.
Signatures started failing after a rotationVerify against v2 as well as v1 during the overlap, and confirm the new secret was deployed everywhere.
No X-HMAC-Signature header at allNo secret exists for that client/subclient pair, so the event was delivered unsigned.
Events stopped arriving after enabling verificationYou are likely returning 4xx on failure, which drops events permanently. Return 5xx.

Summary of the contract

PropertyValue
AlgorithmHMAC-SHA256
Secret format32 random bytes as unpadded base64url, 43 characters, no prefix
HMAC keyThe issued string verbatim, as UTF-8 bytes
Signed content<unix seconds> + . + raw body bytes
Digest encodingLowercase hex, 64 characters
HeaderX-HMAC-Signature: t=...,v1=...[,v2=...]
Rotation overlap48 hours, both digests sent
Secret disclosureOnce, at create and at each successful rotate
Replay enforcementClient-side only
Failure response expected from you5xx, so the event is retried rather than dropped