
A webhook is how your store, billing panel or bot learns that a customer has paid. SnapEX Pay sends an HTTP POST to your URL on every invoice, payment and payout event. Done right, it is the fastest and most reliable signal you have. Done carelessly, it is a way to mark orders paid that were never paid. This guide covers the parts that matter.
Set it up
In the merchant cabinet go to Invoices → the Webhook card, enter your URL and press Save. One URL per account receives every event type. On the first save the cabinet shows the signing secret whsec_… once: keep it on your server only.
The URL must be an absolute https:// address with a public host: no login:password in it, no localhost, and the host must not resolve to a private or reserved IP. Redirects are not followed, so give the final URL.
What arrives
Every request carries four headers:
| Header | Meaning |
|---|---|
Webhook-Id |
Unique event id. It does not change on retries or manual replays. |
Webhook-Timestamp |
Unix time (seconds) when this attempt was sent. Every retry has a fresh value. |
Webhook-Signature |
v1= and the lowercase hex HMAC-SHA256 of {Webhook-Timestamp}.{raw body}. |
Content-Type |
Always application/json. |
The body has id (the same as Webhook-Id), type, created_at and data. For invoice.* events data is the invoice in the same shape as GET /invoices/{id}, including environment, amount_usd, status and your external_id.
The invoice events:
| Event | When |
|---|---|
invoice.created |
Invoice created, no payment method selected yet. |
invoice.awaiting |
A payment method was selected and the address issued. |
invoice.underpaid |
Part of the amount arrived; the same address waits for the rest. |
invoice.paid |
The full amount was paid and credited. The only signal to ship the order. |
invoice.partially_paid |
Only part was paid, the top-up window ended, the received part was credited to you. |
invoice.expired |
The invoice window ended without payment. |
invoice.extra_payment |
Another transfer arrived after the invoice was paid; it is credited separately. |
Verify the signature
- Read the raw request body as bytes, before any JSON parsing. Re-serialized JSON will not match.
- Compute HMAC-SHA256 over
{Webhook-Timestamp}.{raw body}with the whole secret as the key,whsec_prefix included, and hex-encode it. - Compare
v1=+ hex with theWebhook-Signatureheader using a constant-time function. - Reject a
Webhook-Timestampthat differs from your clock by more than 5 minutes. Retries carry a fresh timestamp, so this does not break them.
PHP:
$secret = getenv('SNAPEX_WEBHOOK_SECRET'); // whsec_… from the cabinet
$body = file_get_contents('php://input'); // raw body, before json_decode
$ts = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
$expected = 'v1=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// skip if $event['id'] was already processed; do the heavy work in a queue
http_response_code(200);
Node.js (Express):
const crypto = require('crypto');
app.post('/hooks/snapex', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('Webhook-Timestamp') || '';
const sig = Buffer.from(req.get('Webhook-Signature') || '');
const expected = Buffer.from('v1=' + crypto
.createHmac('sha256', process.env.SNAPEX_WEBHOOK_SECRET)
.update(ts + '.' + req.body)
.digest('hex'));
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
if (!fresh || sig.length !== expected.length || !crypto.timingSafeEqual(sig, expected)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// skip if event.id was already processed; queue the heavy work
res.sendStatus(200);
});
Retries, duplicates and order
- Success is any 2xx. Any other code, a timeout or a network error is a failure. Timeouts are 5 seconds to connect and 15 seconds for the whole request, so reply quickly and do the heavy work asynchronously.
- Retries. Up to 5 attempts per event: the first one, then about 10 seconds, 1 minute, 5 minutes and 15 minutes after each failure. After the fifth failure automatic delivery stops; the cabinet lists recent deliveries and has a Retry button that resends the event with the same
Webhook-Id. - Duplicates. Delivery is at-least-once: the same event may arrive more than once. Store processed
Webhook-Idvalues and ignore repeats. - Order. Events may arrive out of order: a retried old event can come after a newer one. Do not rely on order; compare the status in the event with what you already have.
- Double-check if you like. After a webhook you can re-read the object through the API:
GET /invoices/{id}.
Test it without real money
Create a test invoice ("environment": "test"), pay it on the payment page and watch the webhooks arrive with data.environment = "test". Details: How to test crypto payments without real money.
The ready-made modules for WHMCS, BILLmanager, WooCommerce and other platforms already verify the signature and never credit a payment twice; see pay.snapex.pro. Full reference: SnapEX Pay API documentation.