Verify Webhook Signatures
Merchant API webhooks are signed using HMAC-SHA256 when your project has a nonempty ipnSecret. Configure this secret and reject unsigned notifications. You must verify this signature to ensure that notifications originate from CryptAPI and that the payload was not modified.
Behaviour
- With the project secret configured, each delivery includes a cryptographic signature in
X-CryptAPI-Signature. - The signature is generated as HMAC-SHA256 over a canonical JSON payload, followed by the timestamp.
- You must verify both the signature and the freshness of the timestamp (recommended: allow a maximum drift of
±300s). - Reject any webhook that fails signature validation.
- Verify before processing payment data. A valid signature does not replace idempotency: the same delivery can be replayed within the accepted time window.
Signature headers
X-CryptAPI-Signature— the computed HMAC-SHA256 value.X-CryptAPI-Signature-Timestamp— UNIX timestamp (seconds). Used to reject stale deliveries; duplicate processing must be prevented separately.X-CryptAPI-Signature-Alg— always"HMAC-SHA256".
How the signature is generated
- CryptAPI sorts object keys recursively, preserves array order, primitive types and null, and omits undefined values to produce canonical JSON. Serialize without whitespace.
- The canonical JSON string is concatenated with a
|and the timestamp:canonicalJSON + "|" + timestamp
- HMAC-SHA256 is computed using your
ipnSecret.
// Original received payload:
{
"status": "done",
"value": "1000",
"payment_id": "8806dcee...",
"currency": "uah"
}
// Canonical sorted JSON (no whitespace):
{"currency":"uah","payment_id":"8806dcee...","status":"done","value":"1000"}const canonicalJSON = JSON.stringify(canonicalPayload)
const baseString = canonicalJSON + "|" + timestamp
const signature = crypto
.createHmac("sha256", ipnSecret)
.update(baseString)
.digest("hex")Verification steps
- Read the value of
X-CryptAPI-SignatureandX-CryptAPI-Signature-Timestamp. - Check HMAC-SHA256, a 64-character hexadecimal signature and an integer timestamp within ±300 seconds.
- Parse the JSON payload and sort object keys recursively to rebuild the same canonical JSON CryptAPI used.
- Recompute the HMAC-SHA256 using your project’s
ipnSecret. - Compare the computed signature with
X-CryptAPI-Signatureusing a constant-time comparison. - Reject the webhook if they do not match.
Prefer json=1. The helper below accepts the parsed JSON payload, a Fetch API Headers object (request.headers), and your project secret. Reject malformed JSON before calling it.
GET and form fields are strings and omit null values. Restore types and missing nulls according to the event schema before verification; do not sign the raw query or form body. The integration guide includes a checkout adapter. Deposit, subscription and custom events require their own adapters. Do not convert decimal-string amounts to numbers.
import { createHmac, timingSafeEqual } from 'node:crypto';
function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize).filter(item => item !== undefined);
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.keys(value).sort()
.filter(key => value[key] !== undefined)
.map(key => [key, canonicalize(value[key])])
);
}
return value;
}
export function verifyKingPay(payload, headers, secret, now = Date.now()) {
const signature = headers.get('x-cryptapi-signature') || '';
const timestamp = headers.get('x-cryptapi-signature-timestamp') || '';
if (!secret || headers.get('x-cryptapi-signature-alg') !== 'HMAC-SHA256') return false;
if (!/^[a-f0-9]{64}$/i.test(signature) || !/^\d+$/.test(timestamp)) return false;
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds) || Math.abs(now / 1000 - seconds) > 300) return false;
const expected = createHmac('sha256', secret)
.update(JSON.stringify(canonicalize(payload)) + '|' + timestamp)
.digest();
return timingSafeEqual(expected, Buffer.from(signature, 'hex'));
}