A webhook is the message that says "this order is paid, ship it". If an attacker could send that message themselves, they could get goods for free. That is why every Cryptomo webhook is signed, and why your endpoint must verify the signature before trusting anything in it.
How the signature works
Each webhook request includes three headers:
X-Cryptomo-Event: invoice.paid
X-Cryptomo-Timestamp: 1767225600
X-Cryptomo-Signature: <hex HMAC-SHA256>
The signature is an HMAC-SHA256 of the timestamp, a dot and the raw request body, using your store's webhook secret (it starts with whsec_):
signature = hex( HMAC_SHA256( timestamp + "." + raw_body, webhook_secret ) )
Only Cryptomo and you know the secret, so only Cryptomo can produce a valid signature. Including the timestamp lets you reject old, replayed requests.
Verification in PHP
<?php
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_CRYPTOMO_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_CRYPTOMO_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $raw, getenv('CRYPTOMO_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
$invoice = $event['data'];
if (in_array($invoice['status'], ['paid', 'paid_over', 'paid_late'], true)) {
markOrderPaidOnce($invoice['order_id'], $invoice['id']); // idempotent
}
http_response_code(200);
Verification in Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/webhooks/cryptomo', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-Cryptomo-Timestamp') || '';
const sig = req.get('X-Cryptomo-Signature') || '';
const expected = crypto
.createHmac('sha256', process.env.CRYPTOMO_WEBHOOK_SECRET)
.update(ts + '.' + req.body.toString('utf8'))
.digest('hex');
const valid = sig.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!valid || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
if (['paid', 'paid_over', 'paid_late'].includes(event.data.status)) {
// mark event.data.order_id as paid, once
}
res.sendStatus(200);
});
Five mistakes to avoid
- Parsing before verifying. Compute the HMAC over the raw body. Re-encoded JSON (different spacing or key order) produces a different signature.
- Comparing with
==. Use a constant-time comparison (hash_equals,timingSafeEqual) so the secret cannot be guessed through timing. - Ignoring the timestamp. Reject requests older than about five minutes to stop replays.
- Not being idempotent. The same event can arrive more than once (retries are normal). Record the invoice ID and do nothing if it is already processed.
- Trusting the status blindly for high-value orders. For extra safety, fetch the invoice with
GET /api/v1/invoices/{id}before shipping expensive goods. Our official plugins do this.
Events you will receive
| Event | When |
|---|---|
invoice.paid | Paid in full (also sent for paid_late) |
invoice.partially_paid | Less than the amount arrived |
invoice.overpaid | More than the amount arrived |
invoice.expired | Time ran out |
invoice.cancelled | You cancelled the invoice |
invoice.refunded | A refund you requested was sent |
Reply with any 2xx status. Failed deliveries are retried for 24 hours (after 1 minute, 5 minutes, 15 minutes, 1, 3, 6, 12 and 24 hours), and you can resend any webhook from the dashboard.
Testing
- Use Send test webhook in Dashboard → Stores to check your endpoint
- Open an invoice in the dashboard to see each webhook delivery, the response code and body, and resend it
- Rotate the webhook secret if it was ever exposed, then update your server
Full reference: API documentation → Webhooks. Using PHP? The PHP SDK verifies signatures for you.



