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
- Call
POST /v2/clients/{ClientID}/webhooks/secretswith a body of{}and store the returnedsecretfield. Thesecretis disclosed once and never shown again. - On each delivery, read the
X-HMAC-Signatureheader. - Compute
HMAC-SHA256over<timestamp>.<raw body>using your secret as the key, and hex-encode it. - Compare it to the
v1value 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
| Path | Scope |
|---|---|
| /v2/clients/{ClientID}/webhooks/secrets | Client-level. Used for any subclient that has no secret of its own. |
| /v2/clients/{ClientID}_{SubClientID}/webhooks/secrets | That 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
| HTTP | EC | EM | What it means |
|---|---|---|---|
| 409 | 31818000 | secret | A secret already exists. Use rotate instead — creating never silently replaces a live secret. |
| 400 | 31817000 | subClientID | No configuration exists for this client/subclient pair. The subclient is not boarded yet. |
| 400 | 31811000 | clientID | Client ID missing or zero. |
| 400 | 31812000 | subClientID | Negative subclient ID. |
| 400 | 13364000 | subClientID | The value after the underscore in the path is not a valid non-negative integer. |
| 405 | 13365000 | Method other than POST or PUT. There is no GET or DELETE on this resource. | |
| 500 | 31810000 | A 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
| HTTP | EC | EM | What it means |
|---|---|---|---|
| 409 | 31919000 | secret | No secret exists yet. Create one first. |
| 400 | 31918000 | subClientID | No configuration for this client/subclient pair. |
| 400 | 31911000 | clientID | Client ID missing or zero. |
| 400 | 31912000 | subClientID | Negative subclient ID. |
| 500 | 31910000 | A failure on the TabaPay side. Safe to retry. |
3. The signature on a delivered event
Signed deliveries carry these headers:
| Header | Purpose |
|---|---|
| X-HMAC-Signature | The timestamp and one or two digests. Described below. |
| Idempotency-Key | Stable per event across retries. Use it to deduplicate. |
| Content-Type | Always application/json. |
Header format
X-HMAC-Signature: t=1786000000,v1=77308cd2c7c660a1830f754fa41c8e8302e4897b093c44a4fcdd8685247719a6And during a rotation overlap:
X-HMAC-Signature: t=1786000000,v1=<digest under the current secret>,v2=<digest under the previous secret>| Part | Meaning |
|---|---|
| t | Unix time in seconds, decimal. |
| v1 | Digest keyed by the current secret. Always present. |
| v2 | Digest 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:
- Read the raw body bytes and the
X-HMAC-Signatureheader. - Pull
t,v1, andv2if present. - Reject the delivery if t is outside a freshness window you choose. See section 5.
- Build
t + "." + rawBody. - Compute the HMAC with your current secret and compare, in constant time, to
v1. - If that fails and
v2is present and you still hold the previous secret, compare againstv2too. - 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.
| Input | Value |
|---|---|
| Secret | l-PdbjV5K-6K_LyuZQSN5Q0zMuY55EWN823Q0D4qOk8 (43 characters) |
| Timestamp | 1786000000 |
| Body | 160 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:
7b2269736f223a2231323334222c226d6964223a2239383736353433323130222c227472616e73616374696f6e4944223a2237303331313830303030353233393331313131222c227265666572656e63654944223a225245462d323032362d303030313233222c22616d6f756e74223a2232352e3030222c2275706461746564537461747573223a22434f4d504c45544544222c22636f6465223a223030227dExpected result:
digest 77308cd2c7c660a1830f754fa41c8e8302e4897b093c44a4fcdd8685247719a6
header t=1786000000,v1=77308cd2c7c660a1830f754fa41c8e8302e4897b093c44a4fcdd8685247719a6Negative 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 digest | You made this mistake | Fix |
|---|---|---|
3f4c4cfd090395a0ff8c2b164d333d64a0587d60ab5e8a3719a95664ab57f278 | You base64-decoded the secret and used the resulting bytes as the key. | Use the 43-character string as-is, as UTF-8 bytes. |
1e813f392153664c3d223ec5de606d8994e4e2b9b63486b0398fa0f26a1a0675 | Your 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:
- 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.
- Once you see a clean match rate, switch to enforcing, remembering to fail with a 5xx.
- 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
| Symptom | Likely cause |
|---|---|
406 Not Acceptable when creating a secret | You sent no request body. Send {}. |
409 with EC 31818000 | A secret already exists for this scope. Rotate, or use the one you stored. |
409 with EC 31919000 | You called rotate before ever creating a secret. |
Rotate returned "rotated": false | Expected. A previous rotation's 48-hour window is still open, so the standing secret was re-disclosed. |
| Every signature fails | Almost 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 subclients | Those subclients may have their own subclient-scoped secret that you are not verifying against. |
| Signatures started failing after a rotation | Verify against v2 as well as v1 during the overlap, and confirm the new secret was deployed everywhere. |
No X-HMAC-Signature header at all | No secret exists for that client/subclient pair, so the event was delivered unsigned. |
| Events stopped arriving after enabling verification | You are likely returning 4xx on failure, which drops events permanently. Return 5xx. |
Summary of the contract
| Property | Value |
|---|---|
| Algorithm | HMAC-SHA256 |
| Secret format | 32 random bytes as unpadded base64url, 43 characters, no prefix |
| HMAC key | The issued string verbatim, as UTF-8 bytes |
| Signed content | <unix seconds> + . + raw body bytes |
| Digest encoding | Lowercase hex, 64 characters |
| Header | X-HMAC-Signature: t=...,v1=...[,v2=...] |
| Rotation overlap | 48 hours, both digests sent |
| Secret disclosure | Once, at create and at each successful rotate |
| Replay enforcement | Client-side only |
| Failure response expected from you | 5xx, so the event is retried rather than dropped |
