Coinland PayDocs

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

TypeWhenWhat to do
payment.completedA session was paid and the transfer settledFetch the payment, fulfil the order
session.expiredA session hit expires_at unpaidRelease the cart or reservation
payout.completedA payout or refund settledFetch the payout, close the case it belongs to
binding.completedA customer binding became active -- the ceremony passed, or you approved a reviewMark the customer payable on your side
binding.reviewThe binding's name check failed; that customer cannot pay until you approve or rejectAlert a human; resolve from the console's Customers tab or POST /customers/{id}/binding/review
binding.revokedA binding was severed -- a revoke or a rejectExpect a fresh ceremony on that customer's next payment
test.pingYou pressed Send a test event in the consoleNothing. Verify the signature and return 2xx
payment.completed
{
  "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"
}
session.expired
{
  "event_id": "5c8b1f47-2a93-4d06-b1e8-7f0c3d9a5b62",
  "type": "session.expired",
  "session_id": "3a7f21e8-9c04-4d6b-8e15-7b2a9f3c1d60",
  "reference_id": "order-10492",
  "status": "expired"
}
payout.completed
{
  "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"
}
binding.review
{
  "event_id": "f83a2c16-4d95-4b70-8e21-6c0d9f3a5b48",
  "type": "binding.review",
  "binding_id": "5b8e2f40-7a13-4c96-8d2e-1f6a9c3b70e4",
  "customer_id": "user-1042",
  "status": "pending_review"
}
test.ping
{
  "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...
  • t is the unix timestamp, in seconds, at which the request was signed.
  • v1 is HMAC-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:

  1. 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.
  2. Enforce the five-minute window. Without it, a signature captured once is valid forever.
  3. Compare in constant time. crypto.timingSafeEqual, hash_equals, hmac.compare_digest.
  4. 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_url page polls. The customer is right there and already knows they paid, so read GET /sessions/{id} and show the order as paid the moment it says completed.
  • 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 v1 in the header considered, not just one.
  • Unique constraint on event_id.
  • 2xx returned before the slow work starts.
  • Unknown type ignored rather than thrown on.
  • Amounts read from GET /payments/{id}, never from the payload.

On this page