Verifying Cryptain webhooks in PHP, Node.js and Python

A webhook is the message that says "ship it". Here is how to make sure it really came from us, with code you can paste.

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:

  1. Use the raw body exactly as received. Re-encoding parsed JSON changes the bytes and breaks the signature.
  2. Compare with a constant-time function.
  3. 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.overpaid and invoice.paid_late all mean money arrived. Read data.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.

Keep reading

Open an account in two minutes

Free to start. 1% per payment. Upgrade any time.