Documentation sections
Documentation/Webhooks
Signature verification
X-Signature and X-Signature-V2: spec and ready-made code
Specification
Every webhook carries three signature headers. The recommended scheme is v2:
X-Timestamp- unix time of dispatch, in seconds. Computed for every delivery attempt (retries stretch over more than 24 hours), while the body stays unchanged;X-Signature-V2- HMAC-SHA256 of the string{X-Timestamp}.{raw body}(timestamp, dot, body bytes), lowercase hex;X-Signature- the legacy scheme, HMAC-SHA256 of the raw body only. Kept for compatibility with existing integrations and continues to work.
The key for both schemes is your shop's "Signing secret" from the "Webhook" tab in the dashboard. v2 verification additionally requires X-Timestamp to be fresh (recommended window: 5 minutes either way): a stale timestamp means a replay of an intercepted notification - respond with 401 and do not process it.
Always verify the signature: it is the only guarantee that the notification was sent by TabPay and not by an attacker who discovered your endpoint URL. Ignore notifications with an invalid signature (we will get a non-2xx response and retry the delivery - an extra retry is harmless).
Common pitfalls
- verify against the raw body: parsing the JSON and serializing it back can change key order and whitespace, so the signature will not match. Take the request bytes before parsing;
- compare signatures with a constant-time function (timingSafeEqual, hash_equals, compare_digest), not the string comparison operator;
- for v2 the signed string is exactly "timestamp.body" - take the timestamp from the X-Timestamp header as is, without any transformations;
- keep the server clock accurate (NTP): with clock drift, fresh webhooks will fall outside the tolerance window;
- the tolerance window does not replace idempotency: legitimate delivery retries can arrive hours later - deduplicate by the (id, status) pair as described in webhooks;
- the signing secret is not the API key: they are different values, both available in the dashboard.
Ready-made code
The examples are verified against TabPay's production signing algorithm - copy them as is:
import { createHmac, timingSafeEqual } from 'node:crypto'
// Timestamp tolerance window: older than 5 minutes - treat as a replay.
const TOLERANCE_SECONDS = 300
function safeEqual(expected, actual) {
const a = Buffer.from(expected)
const b = Buffer.from(String(actual || ''))
return a.length === b.length && timingSafeEqual(a, b)
}
// rawBody is the RAW request body (Buffer or string).
// Do not parse and re-serialize the JSON before verifying:
// the signature is computed over the original bytes.
// The recommended scheme is v2: the signature also covers the timestamp.
function isValidWebhook(rawBody, headers, secret) {
const ts = Number(headers['x-timestamp'])
if (!Number.isFinite(ts)) return false
if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false
const expected = createHmac('sha256', secret)
.update(`${headers['x-timestamp']}.${rawBody}`)
.digest('hex')
return safeEqual(expected, headers['x-signature-v2'])
}
// Express example: raw body access is required.
// app.post('/tabpay-webhook',
// express.raw({ type: 'application/json' }),
// (req, res) => {
// const ok = isValidWebhook(
// req.body, // Buffer
// req.headers,
// process.env.TABPAY_WEBHOOK_SECRET,
// )
// if (!ok) return res.status(401).end()
// const payment = JSON.parse(req.body)
// // ... process the payment and respond with 200
// res.status(200).end()
// })