When an invoice changes, Cryptain sends a POST request to your store's webhook URL. Your code reads it and marks the order paid. If anyone could send that request, anyone could mark orders paid. So every webhook is signed, and your endpoint must check the signature before it does anything else.
What arrives
POST /cryptain/webhook
Content-Type: application/json
X-Cryptain-Event: invoice.paid
X-Cryptain-Timestamp: 1791370800
X-Cryptain-Signature: 5c1f0e...
X-Cryptain-Delivery: 8812
{"id":"...","event":"invoice.paid","created_at":"...","data":{ ...the invoice... }}
The signature is HMAC-SHA256(timestamp + "." + raw_body) using your store's webhook secret, written as lowercase hex.
Three rules:
- Use the raw body exactly as received. Re-encoding parsed JSON changes the bytes and breaks the signature.
- Compare with a constant-time function.
- Reject timestamps more than a few minutes old, so a captured request cannot be replayed later.
PHP
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_CRYPTAIN_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_CRYPTAIN_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $raw, getenv('CRYPTAIN_WEBHOOK_SECRET'));
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300 || !hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
Node.js (Express)
import crypto from 'node:crypto';
app.post('/cryptain/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-Cryptain-Timestamp') || '';
const sig = Buffer.from(req.get('X-Cryptain-Signature') || '', 'hex');
const mac = crypto.createHmac('sha256', process.env.CRYPTAIN_WEBHOOK_SECRET)
.update(ts + '.' + req.body).digest();
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300 || sig.length !== mac.length || !crypto.timingSafeEqual(sig, mac)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
res.sendStatus(200);
});
Python (Flask)
import hmac, hashlib, os, time
from flask import request, abort
@app.post("/cryptain/webhook")
def cryptain_webhook():
raw = request.get_data()
ts = request.headers.get("X-Cryptain-Timestamp", "")
sig = request.headers.get("X-Cryptain-Signature", "")
mac = hmac.new(os.environ["CRYPTAIN_WEBHOOK_SECRET"].encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not ts.isdigit() or abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(mac, sig):
abort(401)
event = request.get_json()
return "", 200
The Node.js and Python SDKs include ready-made helpers (verifyWebhook in Node.js, verify_webhook in Python) that do all of the above.
After the signature
- Answer fast. Return a 2xx within a few seconds and do slow work afterwards.
- Expect repeats. If we do not get a 2xx, we retry with growing delays for about two days. Your handler must be safe to run twice for the same invoice: check whether the order is already paid before acting.
- Go by status, not event name.
invoice.paid,invoice.overpaidandinvoice.paid_lateall mean money arrived. Readdata.status. - Double-check large orders. For high-value goods, fetch the invoice from
GET /api/v1/invoices/{id}before shipping. That is what our own plugins do.
Every delivery, with the response your server gave, is listed under Stores → your store → Recent webhook deliveries, where you can also resend one or send a test event.