Webhook signature verification
The payment notification arrives at an address only your store and our service know. But the address can leak—from logs, from configuration, from development history. To tell a genuine notification from a forgery, every request is signed with the webhook secret.
Verify the signature before fulfilling the order or changing its status.
Signature headers
| Header | Value |
|---|---|
X-Timestamp | Send time, unix time in seconds |
X-Signature | The sha256= prefix and the HMAC-SHA256 in hex |
The signed string is the send time, a dot, and the request body:
1756901234.{"wallet":{...},"project":{...},"invoice":{...},"payment":{...}}
The signing key is the Webhook secret field in the project settings. You can reissue it there if the value is compromised.
Verification steps
- Take the raw request body, before parsing the JSON.
- Concatenate the
X-Timestampvalue, a dot, and the body. - Compute the HMAC-SHA256 with the webhook secret.
- Compare the result with
X-Signatureusing a constant-time comparison. - Reject the request if
X-Timestampdeviates too far from the current time.
Implementation examples
- PHP
- TypeScript
- JavaScript
- Python
<?php
$secret = 'webhook secret from your project settings';
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
// Guards against a replayed request
if (abs(time() - (int)$timestamp) > 300) {
http_response_code(400);
exit;
}
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);
// hash_equals compares in constant time
if (!hash_equals($expected, $signature)) {
http_response_code(403);
exit;
}
$data = json_decode($body, true);
// Handle the order
http_response_code(200);
import { createHmac, timingSafeEqual } from 'node:crypto';
import express, { type Request, type Response } from 'express';
const app = express();
const secret = 'webhook secret from your project settings';
// The body must stay raw, so express.raw instead of express.json
app.post('/webhook', express.raw({ type: 'application/json' }), (req: Request, res: Response) => {
const timestamp = req.get('X-Timestamp') ?? '';
const signature = req.get('X-Signature') ?? '';
const body = (req.body as Buffer).toString('utf8');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(400);
}
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${body}`, 'utf8')
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
// Check the length first: timingSafeEqual needs buffers of equal size
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.sendStatus(403);
}
const data = JSON.parse(body);
// Handle the order
res.sendStatus(200);
});
The same handler without types—for a plain JavaScript project.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const app = express();
const secret = 'webhook secret from your project settings';
// The body must stay raw, so express.raw instead of express.json
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Timestamp') ?? '';
const signature = req.get('X-Signature') ?? '';
const body = req.body.toString('utf8');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(400);
}
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${body}`, 'utf8')
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
// Check the length first: timingSafeEqual needs buffers of equal size
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.sendStatus(403);
}
const data = JSON.parse(body);
// Handle the order
res.sendStatus(200);
});
import hmac
import hashlib
import time
from flask import Flask, request
app = Flask(__name__)
secret = 'webhook secret from your project settings'
@app.post('/webhook')
def webhook():
timestamp = request.headers.get('X-Timestamp', '')
signature = request.headers.get('X-Signature', '')
body = request.get_data(as_text=True)
if abs(time.time() - int(timestamp or 0)) > 300:
return '', 400
expected = 'sha256=' + hmac.new(
secret.encode(),
f'{timestamp}.{body}'.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
return '', 403
data = request.get_json()
# Handle the order
return '', 200
Things to keep in mind
The raw body is signed. If you parse the JSON and rebuild it, the key order and spacing change—the signature will not match. Compute the HMAC before parsing.
Every retry has its own signature. The send time is new on each retry, so the signature differs too. Do not store a signature to compare against future requests.
The secret can be reissued. The old secret stops working immediately after reissue, so update the value in your store at the same time.
Signature verification does not replace idempotency. The signature proves the request is authentic, not that you are seeing it for the first time. A retry arrives with a valid signature—check against invoice.id.