Webhooks
Signature verification, the five-minute window, deduplication, retries, and why the payload is only a hint
Webhooks are how Coinland tells you something happened without you polling for it. They are signed POSTs to the https URL you register in your business console.
There are seven events, and one rule that matters more than any of them: the payload is a hint, not a fact. Read the authoritative record before you act on it.
Events
| Type | When | What to do |
|---|---|---|
payment.completed | A session was paid and the transfer settled | Fetch the payment, fulfil the order |
session.expired | A session hit expires_at unpaid | Release the cart or reservation |
payout.completed | A payout or refund settled | Fetch the payout, close the case it belongs to |
binding.completed | A customer binding became active -- the ceremony passed, or you approved a review | Mark the customer payable on your side |
binding.review | The binding's name check failed; that customer cannot pay until you approve or reject | Alert a human; resolve from the console's Customers tab or POST /customers/{id}/binding/review |
binding.revoked | A binding was severed -- a revoke or a reject | Expect a fresh ceremony on that customer's next payment |
test.ping | You pressed Send a test event in the console | Nothing. Verify the signature and return 2xx |
{
"event_id": "d41f8c62-5a10-4e93-b7d8-0c2a5f6e1b34",
"type": "payment.completed",
"session_id": "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
"reference_id": "order-10492",
"payment_id": "b92e4d17-6c38-4a05-9f2b-1e7d3c8a5049",
"status": "completed"
}{
"event_id": "5c8b1f47-2a93-4d06-b1e8-7f0c3d9a5b62",
"type": "session.expired",
"session_id": "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
"reference_id": "order-10492",
"status": "expired"
}{
"event_id": "a17b3e50-9d24-4c81-b6f3-5e0a2c7d1948",
"type": "payout.completed",
"payout_id": "9e3c7a41-0b52-4f18-8d6a-3c7e1f9b40d5",
"reference_id": "payout-2291",
"kind": "payout",
"status": "completed"
}{
"event_id": "f83a2c16-4d95-4b70-8e21-6c0d9f3a5b48",
"type": "binding.review",
"binding_id": "5b8e2f40-7a13-4c96-8d2e-1f6a9c3b70e4",
"customer_id": "user-1042",
"status": "pending_review"
}{
"event_id": "6f2d9a83-1c47-4e05-9b7a-8d3f2e6c05b1",
"type": "test.ping",
"source": "console"
}The three binding.* events share the binding.review shape; status is active on
binding.completed, pending_review on binding.review, and revoked on binding.revoked. As
always, the payload is the hint -- GET /customers/{customer_id}/binding is the authoritative state.
binding.review is the one that deserves an alert: while it stands unresolved, that customer cannot
pay you. See Customer identity binding.
payout.completed fires for refunds too -- kind is payout or refund, so branch on it rather than
assuming. Note that a payout you created yourself has already settled by the time your own API call
returned, so this event mostly matters for payouts made from the business console.
test.ping is the only event you can trigger yourself, from Developers → Webhook deliveries in the
console. It travels the real path -- same queue, same signature, same retries -- so it exercises your
verifier for real. It names no payment, deliberately: there is none, and a handler that treated it as
one would be acting on an order that does not exist.
New event types are additive and can appear without a version bump, so always have a default branch
that ignores what it does not recognise. A handler that throws on an unknown type turns a routine,
backwards-compatible addition into an outage on your side.
Verifying the signature
Every request carries:
x-pay-signature: t=1754999071,v1=3b8a5f9c2d1e...tis the unix timestamp, in seconds, at which the request was signed.v1isHMAC-SHA256(webhook_secret, "{t}.{rawBody}")in lowercase hex.
Note the signed string: the timestamp, a literal dot, then the raw request body exactly as
transmitted. Including t in what is signed is what stops an old, genuine request from being replayed
with a fresh header.
There can be more than one v1, and your verifier must accept any of them. While a
secret rotation is in progress we sign with both the new secret and the outgoing
one:
x-pay-signature: t=1754999071,v1=<new>,v1=<old>That is what lets you deploy the replacement at your own pace instead of during a guaranteed window of
failed checks. The practical consequence is a parsing rule: do not fold the header into a map keyed by
name. Object.fromEntries, dict(...) and their equivalents keep one value per key, so they silently
test only the last signature and reject a delivery signed for the other one. Collect every v1 and
accept the request if any matches. The samples below do this.
import crypto from "node:crypto";
import express from "express";
const app = express();
const webhookSecret = process.env.COINLAND_PAY_WEBHOOK_SECRET!;
// The RAW body is what was signed. A JSON parser that re-serialises the request
// produces different bytes — different key order, different whitespace — and
// every signature fails for reasons that look like a bug in ours. Take the raw
// buffer, verify, then parse.
app.post(
"/webhooks/coinland",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("x-pay-signature") ?? "";
// NOT Object.fromEntries: a map keeps one value per key, and during a
// rotation window the header carries TWO v1 signatures. Collect them all.
let timestamp: number | undefined;
const signatures: string[] = [];
for (const part of header.split(",")) {
const i = part.indexOf("=");
if (i <= 0) continue;
const name = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (name === "t") timestamp ??= Number(value); // first t wins
else if (name === "v1") signatures.push(value);
}
if (timestamp === undefined || !Number.isFinite(timestamp)) return res.sendStatus(400);
// Five-minute window, checked in BOTH directions so a clock ahead of ours
// is rejected too.
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(`${timestamp}.${req.body.toString("utf8")}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const ok = signatures.some((candidate) => {
const b = Buffer.from(candidate, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
if (!ok) return res.sendStatus(400);
// Acknowledge FIRST, work after: a slow handler is a retried handler.
res.sendStatus(200);
void handleEvent(JSON.parse(req.body.toString("utf8")));
},
);Four requirements, all non-negotiable:
- Hash the exact raw bytes. Re-serialising the JSON breaks the signature. Most frameworks need to be told to hand you the raw body; do that before you do anything else.
- Enforce the five-minute window. Without it, a signature captured once is valid forever.
- Compare in constant time.
crypto.timingSafeEqual,hash_equals,hmac.compare_digest. - Accept any
v1, not just the first or the last. Otherwise every rotation you ever perform costs you a window of rejected deliveries.
Receipt tokens are signed with a different secret. That is deliberate: it means rotating this one -- because it leaked, because you changed hosts, because someone left -- has no effect on receipts you have already issued.
Deduplicate on event_id
Assume every event will arrive more than once. Retries, a timeout on your side that we could not tell from a failure, a network partition -- all of them produce a redelivery, and a redelivery is normal operation rather than an error.
event_id is stable across every attempt at the same event. Record it and refuse the second one:
async function handle(event) {
// A unique constraint on event_id is the whole mechanism. Doing this check as
// a SELECT then an INSERT leaves a race two concurrent retries will find.
const inserted = await db
.insert(webhookEvents)
.values({ eventId: event.event_id, type: event.type })
.onConflictDoNothing()
.returning();
if (inserted.length === 0) return; // already handled
if (event.type !== "payment.completed") return;
await fulfil(event);
}Idempotency on your side is what makes the retry policy safe. Without it, one blip double-ships an order.
The payload is a hint
The body carries ids and a status. It deliberately carries no amount, because a webhook body is something that arrives at your server from the network, and an amount you would act on should come from a call you made.
So the handler pattern is always the same: verify, dedupe, then read.
const API = "https://my.coinlandexchange.com";
// The webhook payload is a HINT. This is the authority.
const payment = await fetch(`${API}/api/pay/v1/payments/${event.payment_id}`, {
headers: { Authorization: `Bearer ${process.env.COINLAND_PAY_KEY}` },
}).then((r) => r.json());
if (payment.reference_id !== order.id) return; // not this order
if (payment.currency !== order.currency) return; // not what we quoted
if (new Decimal(payment.amount).lt(order.total)) return; // underpaid
await fulfil(order, payment);That fetch is the one authoritative read in the whole flow. GET /payments/{id} and
GET /sessions/{id} are the two calls whose answers you can build on.
Answer fast, work afterwards
Return a 2xx as soon as the signature checks out. Do the fulfilment work after you have responded, or hand it to a queue.
A handler that ships the goods, sends the email and updates the warehouse before responding is a handler that will eventually take longer than the delivery timeout. What happens then is that the work succeeds and the delivery is recorded as failed, so it is retried -- and your idempotency check is now the only thing standing between one order and two.
Retries and backoff
A delivery is successful if you answer with any 2xx. Anything else -- a 4xx, a 5xx, a timeout, a TLS failure, a DNS failure -- is a failed attempt, and Coinland retries with exponential backoff over roughly 24 hours before giving up.
Because deliveries can be hours apart, do not rely on the webhook as your only path. Two habits make an integration robust to a webhook that never lands:
- Your
return_urlpage polls. The customer is right there and already knows they paid, so readGET /sessions/{id}and show the order as paid the moment it sayscompleted. - A sweep catches the rest. Once a day, list your recent sessions with unpaid orders attached and reconcile them. This also catches an order that was paid while your server was down.
Retries change nothing about correctness if you dedupe. They only change how long a missed delivery takes to arrive.
Registering your endpoint
Set your URL in Settings → Webhook. Save it and your signing secret is shown in the same step -- that is the value your verifier needs, and you can open it again later from the same card. Requirements:
- https only. A plaintext webhook URL is refused with
MERCHANT_WEBHOOK_URL_INVALID, along with anything unparsable or pointed at a private address. - Publicly reachable. We cannot deliver to localhost or an address inside your VPN.
- One URL. If you need to fan out to several services, receive once and publish internally.
Once it is set, Developers → Webhook deliveries shows every event we queued for you, how many
attempts it took, and why an attempt failed -- including the failures that never reached you at all,
such as a hostname that resolves to a private address. Send a test.ping from there before you go live.
The test button refuses until you have opened your webhook signing secret at least once
(MERCHANT_WEBHOOK_SECRET_UNSEEN). That is deliberate: without the secret on your server you could not
verify what arrives, so the delivery would fail your own signature check and look like our fault.
Rotating a secret
Press Replace on the signing secret. You get the new value immediately and the previous secret keeps working for 24 hours: every delivery in that window is signed with both, so a verifier that follows the rule above accepts them under either key. Deploy the replacement whenever you like within the window; after it, only the new secret is accepted.
There is no need to plan an outage, and no need to hold two secrets in your own configuration -- the overlap is on our side.
The receipt secret is a separate key with a separate button, and rotating it
is not free: once its 24-hour window closes, every receipt token you have ever issued stops verifying —
offline and through POST /receipts/verify, which accepts the same two keys you do. Rotate that one
only if the secret itself has leaked; GET /payments/{id} is the durable proof of an old payment.
Checklist
- Raw body, not a re-serialised one.
- Timestamp window enforced, both directions.
- Constant-time signature comparison.
- Every
v1in the header considered, not just one. - Unique constraint on
event_id. - 2xx returned before the slow work starts.
- Unknown
typeignored rather than thrown on. - Amounts read from
GET /payments/{id}, never from the payload.