TillsafeDocstillsafe.comRequest early access

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:

HeaderValue
x-cryptopay-signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
x-cryptopay-event-idevt_…: the same for every retry and replay of one event
x-cryptopay-event-typee.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:

  1. Take the raw request body bytes. Parse the JSON only after verifying; re-serialising changes bytes.
  2. Split the header on ,. Read t and every v1 (there can be several while a secret is being rotated). Ignore any other scheme.
  3. Reject if |now - t| > 300 seconds (replay protection).
  4. Compute the HMAC and compare it with each v1 in constant time. Accept if any matches.
  5. Deduplicate on x-cryptopay-event-id: delivery is at-least-once, and replays resend the same event.
  6. Answer 2xx within 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 at GET /v1/webhook_deliveries, and any delivery can be replayed.

The body:

json
{
  "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:

TypeWhendata
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 nothingobject: the invoice
invoice.payment_reverseda credited payment was undone by a chain reorganisation (the invoice's status webhook follows)object: the invoice, payment: the payment (status: "reversed")
invoice.wrong_asseta token the invoice did not ask for arrived at its address; it is refundableobject, payment
invoice.late_paymenta payment arrived after the invoice expired or was cancelled; accept it or refund itobject, payment
invoice.review_resolvedyou 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.cancelledobject: the invoice after the decision, review: {decision: "accept" | "reject", reason, via: "api" | "dashboard"}
refund.claimedthe payer chose where to receive a refund; check payout.screening and send it from your walletobject: the refund
payment_link.created, payment_link.updated, payment_link.deactivateda 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 linkobject: the payment link
subscription.createdPOST /v1/subscriptionsobject: the subscription
subscription.invoice_createda renewal invoice, or a retry's fresh invoice, was created (also for the first period); the top-level invoice_id names itobject, invoice
subscription.reneweda period was paid: by its invoice (invoice present) or entirely by prepaid credit (invoice absent)object, invoice?
subscription.past_duea renewal is still unpaid when the plan's grace period ends; retries continueobject
subscription.paused, subscription.resumedyou paused or resumed itobject
subscription.updatedcancel_at_period_end was setobject
subscription.cancelledfinal: 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)

js
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
<?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)

python
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)

go
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