Webhooks and signature verification
Get told when an invoice is paid, and prove the message came from Tillsafe, in Node.js, Python, PHP or Go.
A webhook is how you learn that money arrived. Ship the order, unlock the account or mark the bill paid
when your endpoint receives a verified invoice.paid event, never on a redirect or a message from the
payer's browser: those can be forged, a signed webhook cannot.
Setting the endpoint. In test mode, set it with POST /v1/merchant/settings
({"webhook_url": "https://…"}) and get a signing secret with POST /v1/merchant/webhook_secret/roll,
as in the quickstart. In live mode an API key cannot change either
(403 live_change_requires_dashboard): the change is made on the dashboard, with a passkey confirmation,
an email to every owner and admin, and a delay before it takes effect (see the
security model).
Where it can point. The URL must be https://, without credentials, and resolve to a public
address. Deliveries never follow redirects.
Every snippet on this page is run by the docs' test suite against a real server: it receives real deliveries, accepts them, and rejects forged, stale and tampered ones.
Every webhook is an HTTPS POST with a JSON body and three headers:
| Header | Value |
|---|---|
x-cryptopay-signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
x-cryptopay-event-id | evt_…: the same for every retry and replay of one event |
x-cryptopay-event-type | e.g. invoice.paid |
The signature is HMAC-SHA256(key = your webhook secret, message = "{t}.{raw body}"), hex-encoded.
The key is the whole secret string as UTF-8, including the whsec_ prefix. Get a secret with
POST /v1/merchant/webhook_secret/roll (it is shown once).
To accept a delivery:
- Take the raw request body bytes. Parse the JSON only after verifying; re-serialising changes bytes.
- Split the header on
,. Readtand everyv1(there can be several while a secret is being rotated). Ignore any other scheme. - Reject if
|now - t| > 300seconds (replay protection). - Compute the HMAC and compare it with each
v1in constant time. Accept if any matches. - Deduplicate on
x-cryptopay-event-id: delivery is at-least-once, and replays resend the same event. - Answer
2xxwithin 10 seconds. Do slow work after answering. Anything else (including a redirect, which is never followed) counts as a failure and is retried with exponential backoff for about 72 h. Every attempt, with its status code and the first 1 KiB of your response, is listed atGET /v1/webhook_deliveries, and any delivery can be replayed.
The body:
{
"id": "evt_…",
"object": "event",
"type": "invoice.paid",
"livemode": false,
"created_at": "2026-09-27T20:15:04Z",
"invoice_id": "inv_…",
"data": { "object": { "id": "inv_…", "object": "invoice", "status": "paid", "…": "…" } }
}Every invoice and refund event carries the top-level invoice_id of the invoice it is about (the delivery
log lists it too, as invoice on each GET /v1/webhook_deliveries row). payment_link.* events are about
no single invoice, so theirs is null. Subscription events carry it
when they are about an invoice.
Event types:
| Type | When | data |
|---|---|---|
invoice.created, invoice.detected, invoice.paid, invoice.{status} | the invoice reached a status (partially_paid, overpaid, paid_late, expired, under_review, cancelled); confirmations alone send nothing | object: the invoice |
invoice.payment_reversed | a credited payment was undone by a chain reorganisation (the invoice's status webhook follows) | object: the invoice, payment: the payment (status: "reversed") |
invoice.wrong_asset | a token the invoice did not ask for arrived at its address; it is refundable | object, payment |
invoice.late_payment | a payment arrived after the invoice expired or was cancelled; accept it or refund it | object, payment |
invoice.review_resolved | you accepted or rejected a held payment (POST /v1/invoices/{id}/resolve_review); the status webhook (invoice.paid, invoice.cancelled, …) is sent too. POST /v1/invoices/{id}/cancel sends invoice.cancelled | object: the invoice after the decision, review: {decision: "accept" | "reject", reason, via: "api" | "dashboard"} |
refund.claimed | the payer chose where to receive a refund; check payout.screening and send it from your wallet | object: the refund |
payment_link.created, payment_link.updated, payment_link.deactivated | a payment link was created, changed, or switched off (active: false). These name no invoice: invoice_id is null. Each payer's invoice sends the ordinary invoice.* events, and its payment_link field names the link | object: the payment link |
subscription.created | POST /v1/subscriptions | object: the subscription |
subscription.invoice_created | a renewal invoice, or a retry's fresh invoice, was created (also for the first period); the top-level invoice_id names it | object, invoice |
subscription.renewed | a period was paid: by its invoice (invoice present) or entirely by prepaid credit (invoice absent) | object, invoice? |
subscription.past_due | a renewal is still unpaid when the plan's grace period ends; retries continue | object |
subscription.paused, subscription.resumed | you paused or resumed it | object |
subscription.updated | cancel_at_period_end was set | object |
subscription.cancelled | final: you cancelled it (cancellation_reason: "requested", now or at the period end), or every payment attempt closed unpaid ("unpaid") | object |
Subscription events carry invoice_id only when they are about an invoice. See
the subscriptions guide for the billing cycle.
Test vector: secret key, t = 1700000000, body {} gives
t=1700000000,v1=9d713ed406bb7076d4123f0dc2c39d2df5c654ed4b0cd56b52c8b4c940bd63ae.
Node.js (no dependencies)
const crypto = require("node:crypto");
/** Returns true if `header` is a valid signature of `rawBody` (a Buffer) under `secret`. */
function verifyWebhook(rawBody, header, secret, toleranceSecs = 300, nowSecs = Math.floor(Date.now() / 1000)) {
let t = null;
const sigs = [];
for (const part of String(header || "").split(",")) {
const i = part.indexOf("=");
if (i < 0) return false;
const k = part.slice(0, i).trim(), v = part.slice(i + 1).trim();
if (k === "t") t = Number(v);
else if (k === "v1") sigs.push(v);
}
if (!Number.isInteger(t) || sigs.length === 0) return false;
if (Math.abs(nowSecs - t) > toleranceSecs) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return sigs.some((s) => {
const got = Buffer.from(s, "hex");
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}
// With node:http (the raw body is what you read off the socket):
require("node:http").createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c)).on("end", () => {
const raw = Buffer.concat(chunks);
if (!verifyWebhook(raw, req.headers["x-cryptopay-signature"], process.env.WEBHOOK_SECRET)) {
res.writeHead(400).end("bad signature");
return;
}
const event = JSON.parse(raw.toString("utf8"));
// Deduplicate on event.id (== the x-cryptopay-event-id header), then handle event.type.
res.writeHead(200).end("ok");
});
}).listen(Number(process.env.PORT) || 3000);With Express, verify before any JSON body parser: app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => verifyWebhook(req.body, …)).
PHP
<?php
/** True if $header is a valid signature of the raw body $payload under $secret. */
function verify_webhook(string $payload, ?string $header, string $secret, int $tolerance = 300): bool {
if ($header === null) return false;
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) !== 2) return false;
if ($kv[0] === 't' && ctype_digit($kv[1])) $t = (int) $kv[1];
elseif ($kv[0] === 'v1') $sigs[] = $kv[1];
}
if ($t === null || !$sigs) return false;
if (abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) return true; // constant-time
}
return false;
}
$payload = file_get_contents('php://input'); // the raw body
$header = $_SERVER['HTTP_X_CRYPTOPAY_SIGNATURE'] ?? null;
if (!verify_webhook($payload, $header, getenv('WEBHOOK_SECRET'))) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($payload, true);
// Deduplicate on $event['id'], then handle $event['type'].
http_response_code(200);
echo 'ok';Try it locally with PHP's built-in server: php -S 0.0.0.0:3000 receiver.php.
Python (standard library only)
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
def verify_webhook(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
"""True if `header` is a valid signature of the raw body bytes under `secret`."""
if not header:
return False
t, sigs = None, []
for part in header.split(","):
key, sep, value = part.strip().partition("=")
if not sep:
return False
if key == "t" and value.isdigit():
t = int(value)
elif key == "v1":
sigs.append(value)
if t is None or not sigs:
return False
if abs(int(time.time()) - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), s.encode()) for s in sigs) # constant-time
class Webhook(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) # the raw body
if not verify_webhook(raw, self.headers.get("x-cryptopay-signature"), os.environ["WEBHOOK_SECRET"]):
self.send_response(400)
self.end_headers()
self.wfile.write(b"bad signature")
return
event = json.loads(raw)
# Deduplicate on event["id"], then handle event["type"].
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
HTTPServer(("", int(os.environ.get("PORT", "3000"))), Webhook).serve_forever()With Flask or Django, verify request.get_data() / request.body (the raw bytes), never a re-serialised
request.json.
Go (standard library only)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// verifyWebhook reports whether header is a valid signature of rawBody under secret.
func verifyWebhook(rawBody []byte, header, secret string, tolerance time.Duration) bool {
t := int64(-1)
var sigs []string
for _, part := range strings.Split(header, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
return false
}
switch k {
case "t":
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 0 {
return false
}
t = n
case "v1":
sigs = append(sigs, v)
}
}
if t < 0 || len(sigs) == 0 {
return false
}
if age := time.Since(time.Unix(t, 0)); age > tolerance || age < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, s := range sigs {
if got, err := hex.DecodeString(s); err == nil && hmac.Equal(got, expected) { // constant-time
return true
}
}
return false
}
func main() {
secret := os.Getenv("WEBHOOK_SECRET")
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // the raw body
if err != nil || !verifyWebhook(raw, r.Header.Get("x-cryptopay-signature"), secret, 5*time.Minute) {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
var event struct {
ID string `json:"id"`
Type string `json:"type"`
}
if err := json.Unmarshal(raw, &event); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// Deduplicate on event.ID, then handle event.Type.
w.Write([]byte("ok"))
})
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
log.Fatal(http.ListenAndServe(":"+port, nil))
}Your endpoint URL
webhook_url must be a public https:// URL. Private, loopback, link-local and other internal addresses
are refused when you set it and again at every delivery (including after DNS resolution), URLs with
credentials are refused, and redirects are not followed.
Testing your endpoint
- In test mode,
POST /v1/test_helpers/invoices/{id}/simulate_paymentpays an invoice through the real pipeline, so your endpoint gets the sameinvoice.detectedandinvoice.paidevents a real payment sends. Testing with simulate_payment covers every scenario. GET /v1/webhook_deliverieslists every attempt with the status code and the start of your response. When your endpoint answered with an error, that is where you see why.POST /v1/webhook_deliveries/{id}/replaysends a delivery again, with the same event id, so you can test your deduplication.- A late payment cannot be accepted through the API today:
invoice.late_paymentmeans refund it, or fulfil the order by hand (late payments).