Webhooks for crypto payments: verifying the signature and handling retries
CoinsFlow ·
How a CoinsFlow webhook is signed (t and v1, HMAC-SHA256 over the raw body), how to verify it in Node and Python with a test vector you can run, the retry schedule, how to make your handler idempotent, and every event you can subscribe to.
A webhook is a POST request from CoinsFlow to your server when something happens to an invoice or a withdrawal. It is how your shop learns, without polling, that a payment is paid or a withdrawal confirmed. Because anyone on the internet can POST to your URL, the request is signed, and because the internet drops things, it is retried.
What arrives
Every delivery is a JSON body with these headers:
Content-Type: application/jsonUser-Agent: CoinsFlow-Webhooks/1.0CoinsFlow-Event: the event type, such asinvoice.paidCoinsFlow-Delivery: the delivery's id, the same asidin the bodyCoinsFlow-Signature:t=<unix seconds>,v1=<hex>
The body is an envelope:
{
"id": "5d0b8a52-6c1e-4f7a-9b3d-2e8f0a1c4b6d",
"type": "invoice.paid",
"createdAt": "2026-10-24T09:12:33.000Z",
"previousStatus": "confirming",
"data": { "object": "invoice", "id": "...", "status": "paid", "amountDue": "20.00", "amountPaid": "20.00", "metadata": { "orderId": "1042" }, "payments": [ ] }
}data is the whole object as it stands now: for an invoice, its status, chain, asset, address, amounts as strings, your orderId and metadata, timestamps and payments. An invoice.payment event adds a payment field naming the one payment it is about. A test event adds "test": true. The id is the delivery's: the same event retried has the same id, and the same event sent to two of your endpoints has two.
The signature
v1 is HMAC-SHA256, keyed with your endpoint's secret (it starts with whsec_), over the string <t>.<raw body>: the timestamp, a dot, and the exact bytes of the body. To verify:
- Read the raw body bytes before any JSON parser touches them. A re-serialised body will not match.
- Split the header on commas and
=to gettandv1. - Reject if
tis more than 300 seconds from your clock. That stops a captured request from being replayed later. - Compute the HMAC over
t, a dot and the raw body. Compare it tov1in constant time.
A vector from the API docs to check your code against. Secret whsec_example_secret_for_testing_only, t of 1790590000, and this body on one line with no trailing newline:
{"id":"5d0b8a52-6c1e-4f7a-9b3d-2e8f0a1c4b6d","type":"ping","createdAt":"2026-10-20T10:00:00.000Z","data":{"type":"ping","message":"Test event from CoinsFlow. If you can read this, your endpoint works."}}The expected v1 is 4deb3dee012a9ea4a9918068c54b03b60d2fda04a6153b4583c42dc51508bf38. If your function returns that, it is right.
Node (Express)
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.COINSFLOW_WEBHOOK_SECRET;
function verified(header, rawBody) {
const parts = Object.fromEntries(String(header ?? "").split(",").map((p) => p.split("=", 2)));
const t = String(parts.t ?? "");
if (!/^\d{1,12}$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(rawBody).digest();
const given = Buffer.from(String(parts.v1 ?? ""), "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
const app = express();
app.post("/webhooks/coinsflow", express.raw({ type: "application/json" }), async (req, res) => {
if (!Buffer.isBuffer(req.body) || !verified(req.get("CoinsFlow-Signature"), req.body)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
if (event.test) return res.sendStatus(200); // acknowledged, not acted on
if (await alreadySeen(event.id)) return res.sendStatus(200);
await queue(event); // do the slow work elsewhere
await markSeen(event.id);
res.sendStatus(200);
});express.raw matters: express.json() would parse the body first and leave you nothing to sign over.
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["COINSFLOW_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
def verified(header: str, body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not (t.isascii() and t.isdigit()) or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1.encode("utf-8", "replace"), expected.encode())
@app.post("/webhooks/coinsflow")
def coinsflow():
body = request.get_data() # raw bytes, before any parsing
if not verified(request.headers.get("CoinsFlow-Signature", ""), body):
abort(400)
event = request.get_json(force=True)
if event.get("test") or already_seen(event["id"]):
return "", 200
queue(event)
mark_seen(event["id"])
return "", 200Retries
CoinsFlow treats anything but a 2xx answer within 10 seconds as a failure: a 4xx, a 5xx, a timeout, a refused connection, or a redirect, which is not followed. A failed delivery is tried again after 3 seconds, then 30 seconds, 5 minutes, 1 hour, 6 hours and 24 hours: seven attempts over about 31 hours. After the last one the delivery is marked failed and you are told by email. Every attempt, with what was sent and what you answered, is in the dashboard's delivery log, where you can resend by hand.
Two more rules protect you and us. If an endpoint accepts nothing for six hours, you are emailed. If it accepts nothing for five days, it is switched off, and nothing is sent to it or kept for it until you turn it back on.
Three consequences for your code:
- Answer fast, work later. Verify, store, answer
200, then do the slow part (send an email, call Discord, update stock) from a queue or a background job. A handler that takes 12 seconds is retried, and does the work twice. - Be idempotent. Keep the delivery ids you have handled and ignore repeats. Also key the business action on the thing itself: once per invoice id, not once per event.
- Trust the status, not the order. Act on
data.statusin the body. Ifinvoice.confirmingarrives afterinvoice.paidwith an oldercreatedAt, the invoice is paid. If you receiveinvoice.pendingafterpaid, the payment did not survive a reorganisation: treat the latest status as the truth.
The events
Invoice events:
invoice.detected: a payment was seen, not yet in a block. Show "payment seen". Not a default; tick it when you add the endpoint.invoice.confirming: the payment is in a block, gaining confirmations.invoice.pending: the invoice is open again because its payment was dropped or reorganised away.invoice.paid: paid in full at its confirmations. Fulfil the order.invoice.underpaid: less arrived than due;amountRemainingsays how much is missing.invoice.overpaid: more arrived than due;amountOverpaidsays how much. All of it is yours.invoice.payment: one payment was credited, whatever it did to the status. Sent for every payment.invoice.settled: final depth; the network can no longer reverse it.invoice.expired: nothing arrived in time.invoice.cancelled: cancelled by you.
Withdrawal events:
withdrawal.broadcast: sent to the network;txidis set.withdrawal.confirmed: confirmed on chain.withdrawal.failed: could not complete; the amount and fee were returned to your balance.withdrawal.cancelled: cancelled before sending.withdrawal.stuck: sent but not progressing; our team is alerted.
The defaults for a new endpoint are invoice.paid, invoice.underpaid, invoice.overpaid, invoice.settled, invoice.expired, invoice.pending, withdrawal.confirmed, withdrawal.failed and withdrawal.cancelled. Pick exactly what your code handles.
Setting up the endpoint
A URL must be https://, on the public internet, with no username or password in it. You can have up to 10 endpoints per merchant, each with its own secret and its own list of events. Whenever an endpoint is added, changed, removed or given a new secret, the account owner is emailed, so a key in the wrong hands cannot quietly point invoice.paid somewhere else. Press "Send test" to receive a ping and prove the signature check end to end, then make a test invoice ("test": true) and simulate paid to exercise your real handler.
The full reference, with a PHP sample and example payloads, is in the API docs; the retry rules are under Retries.
More articles
- Test crypto payments without spending a coin
- Crypto payment gateway fees compared (October 2026)
- USDT on BNB Smart Chain vs TRON vs Ethereum: what a $20 payment really costs
- How long does a Litecoin payment take to confirm, and why CoinsFlow counts one confirmation
- How to accept Litecoin payments in a Discord bot (2026 guide)