Skip to content
Crypto Merchant

Developers

Crypto Payment Webhooks for SaaS: Retries, Idempotency and Reconciliation

How to handle crypto payment webhooks in production: retry schedule, duplicate and out-of-order events, safe account crediting, and a daily reconciliation job.

By the Crypto Merchant team11 min read

A crypto payment webhook can arrive twice, out of order, or not at all if your server is down for long enough, so it should start your payment logic rather than be all of it. Save each event before you reply, skip event IDs you have already handled, read the invoice's current status from the API before you grant access, and run a scheduled job that compares paid invoices with your own records. Crypto Merchant retries a failed webhook up to eight times over about two days, which sets how long an outage your system can recover from without that job. Last checked: 5 November 2026.

This guide assumes you have already created an invoice and checked a signature. If not, start with the crypto payment API guide.

When does Crypto Merchant retry a webhook?

It retries when your endpoint does not reply with a 2xx status within 10 seconds. Any other status, a timeout or a connection error counts as a failure. Each retry is signed again with a fresh timestamp and carries the same event id.

AttemptWait after the previous failureTime since the first attempt
First deliveryNone0
Retry 11 minute1 minute
Retry 25 minutes6 minutes
Retry 315 minutes21 minutes
Retry 41 hour1 h 21 min
Retry 53 hours4 h 21 min
Retry 66 hours10 h 21 min
Retry 712 hours22 h 21 min
Retry 824 hours46 h 21 min

Times are approximate, because attempts run on a worker. After the last retry, that event is not sent again. Under Developers in the portal you can see recent deliveries with their status, response code and number of attempts.

Why do webhook events arrive twice or out of order?

Delivery is at-least-once, and events are not guaranteed to arrive in order. The usual cause of a duplicate is on your side: your server did the work but the reply was slow or lost, so the event counted as failed and was sent again. Ordering breaks for a similar reason. If invoice.confirming fails and is retried a minute later, invoice.paid may reach you first.

Three rules follow from this:

  • Never treat the arrival of an event as proof that it has not been handled. Check the event id.
  • Never let a late event move an order backwards. A retried invoice.confirming must not undo a paid order.
  • Do not store only the last event's status and trust it. Read the invoice when you are about to do something that matters.

How do I make a webhook handler idempotent?

Give your events table a unique key on the event id, insert the event, and reply 200 whether the row is new or already there. Reply with an error only if the save itself failed, so the event is sent again.

PostgreSQL
CREATE TABLE webhook_events (
  event_id     text PRIMARY KEY,
  type         text NOT NULL,
  payload      jsonb NOT NULL,
  received_at  timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz
);

A Node.js handler using Express and PostgreSQL. verifyWebhook is the function from the webhooks docs:

server.js
import express from "express";
import { verifyWebhook } from "./verify-webhook.js";
import { db } from "./db.js";

const app = express();

// express.raw keeps the body exactly as it arrived, which the signature needs.
app.post("/webhooks/crypto-merchant", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = verifyWebhook(req.body, req.get("CM-Signature"), process.env.CM_WEBHOOK_SECRET);
  } catch {
    return res.sendStatus(400);
  }
  try {
    await db.query(
      `INSERT INTO webhook_events (event_id, type, payload) VALUES ($1, $2, $3)
       ON CONFLICT (event_id) DO NOTHING`,
      [event.id, event.type, event],
    );
  } catch {
    return res.sendStatus(500); // not saved: let it be sent again
  }
  res.sendStatus(200); // saved: do the work in a background job
});

The same signature check in Python, for a server that isn't on Node:

verify_webhook.py
import hashlib, hmac, json, time

def verify_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_sec: int = 300) -> dict:
    parts = dict(part.strip().split("=", 1) for part in signature_header.split(","))
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        raise ValueError("Missing signature")
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        raise ValueError("Bad signature")
    if abs(time.time() - int(t)) > tolerance_sec:
        raise ValueError("Timestamp too old")
    return json.loads(raw_body)

The five-minute timestamp check does not get in the way of retries, because every attempt is signed with a new timestamp. If you have more than one endpoint, each receives the same event id, so keep a separate table per system rather than sharing one.

What should each event do in a SaaS app?

Only a few events should change anything a customer can see. Use the others for status messages.

EventWhat it meansWhat your app should do
invoice.confirmingA payment was seen and is waiting for confirmationsShow “payment received, confirming”. Grant nothing
invoice.paidPaid and confirmedFetch the invoice, then grant access or credit the account, once
invoice.underpaidConfirmed, but short by more than your toleranceShow “payment incomplete”. The customer can pay the rest or ask for a refund
invoice.expiredTime ran out before any payment was seenShow “expired” and offer a new invoice, but keep the order open (see below)
invoice.cancelledThe invoice was cancelledClose the order
invoice.refundedCoins went back to the customer. Sent for each refund claimRead refunded_amount on the invoice, and adjust access if your policy requires it
deposit.confirmedA sticky wallet deposit is finalCredit that customer, once per deposit id
deposit.droppedA deposit that was seen never confirmedRemove any “pending” balance shown

Two of these catch people out.

invoice.expired is not always the end. An expired invoice's address is still watched for at least 24 hours, and a late payment can still count towards the invoice. Don't delete the order or reuse its ID when this event arrives. Wrong coins and late payments are covered in the checkout docs.

invoice.refunded is sent once per claim, and partial refunds keep the invoice paid. Use the total in refunded_amount. If you add up events yourself, a duplicate will count twice unless you have already filtered it by event id.

How do I credit an account exactly once?

Put a unique key on the thing you are crediting, and make the credit and the "handled" mark one database transaction. The event id stops a repeated delivery. The invoice or deposit id stops anything else that reaches the same payment, such as your reconciliation job.

jobs/handle-paid.js
// Runs in a background job, for one saved invoice.paid event.
async function handlePaid(event) {
  const res = await fetch(`https://cryptomerchant.online/api/v1/invoices/${event.data.id}`, {
    headers: { Authorization: `Bearer ${process.env.CM_SECRET_KEY}` },
  });
  if (!res.ok) throw new Error(`Invoice lookup failed: ${res.status}`); // the job retries later
  const invoice = await res.json();
  if (invoice.status !== "paid") return; // the invoice decides, not the event

  await db.transaction(async (tx) => {
    // credits.invoice_id is UNIQUE, so a second attempt inserts nothing.
    const { rowCount } = await tx.query(
      `INSERT INTO credits (invoice_id, order_id, amount_usd) VALUES ($1, $2, $3)
       ON CONFLICT (invoice_id) DO NOTHING`,
      [invoice.id, invoice.order_id, invoice.amount_usd],
    );
    if (rowCount === 1) await grantAccess(tx, invoice.order_id);
    await tx.query(`UPDATE webhook_events SET processed_at = now() WHERE event_id = $1`, [event.id]);
  });
}

For prepaid balances with sticky wallets, the pattern is the same with one change: key on the deposit id from deposit.confirmed. Then decide who carries the fee. Credit net_amount to pass the fee to your customer, or amount to cover it yourself.

How do I reconcile crypto payments with my database?

Run a scheduled job that lists recent paid invoices from the API and checks each one against your own records. Run it at least daily, which is well inside the two-day retry window, so a missed event is found long before anyone complains.

GET /invoices returns invoices newest first, up to 100 a page. Keep asking with starting_after while has_more is true, and stop when created_at is older than your lookback window.

jobs/reconcile.js
// Finds paid invoices with no credit in your database.
const API = "https://cryptomerchant.online/api/v1";
const LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;

async function reconcile() {
  const cutoff = Date.now() - LOOKBACK_MS;
  let after;
  while (true) {
    const url = new URL(`${API}/invoices`);
    url.searchParams.set("status", "paid");
    url.searchParams.set("limit", "100");
    if (after) url.searchParams.set("starting_after", after);

    const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.CM_SECRET_KEY}` } });
    if (!res.ok) throw new Error(`List failed: ${res.status}`);
    const page = await res.json();

    for (const invoice of page.data) {
      if (Date.parse(invoice.created_at) < cutoff) return;
      await creditIfMissing(invoice); // same INSERT ... ON CONFLICT as the handler
    }
    if (!page.has_more) return;
    after = page.data.at(-1).id;
  }
}

A seven-day lookback is a sensible default. It covers the retry window, a long weekend and late payments on expired invoices. Sticky wallets have their own list: GET /deposits?status=confirmed, paged the same way, as the docs describe.

Then check the other direction. Any order your database marks as paid that the API does not show as paid should go to a person, not be fixed automatically.

Which fields should I compare when reconciling?

Compare the invoice's own fields, and don't expect received_amount to equal the price. The amount a customer sends can include things that aren't yours.

FieldWhat it tells youCommon mistake
statusThe current state of the invoiceUsing the last webhook you saw instead
amount, currency, amount_usdThe price you asked forComparing the price directly with coins received
pay_amountThe exact amount the customer was asked to send, in the coinForgetting that it includes network_fee_amount
network_fee_amount, network_fee_usdThe gas fee the customer pays on top of the price for moving this paymentBooking it as your revenue
received_amount, received_usdWhat actually arrivedTreating all of it as yours
overpaid_amountExtra sent above the invoice. It is held for the customer, who withdraws it from the invoice page, and isn't added to your balanceCounting it as income
refunded_amountThe total refunded through refund linksAdding up refund events yourself
coin, requested_coinThe coin paid, and the coin asked for if they differAssuming the paid coin is the one you requested
order_id, metadataYour own references, returned unchangedNot setting them, then matching by amount

For your books, the 1% fee is deducted in the coin paid, when the payment is credited to your balance. GET /balances shows what you hold in each coin and your gas balance. Moving coins out later carries a gas fee, from the gas balance or the coins being moved, so a payout won't match an invoice total to the last decimal.

What happens if my server is down for more than two days?

Events from that period stop being retried, and the API is how you catch up. There is no endpoint to list past webhook events, so list invoices and deposits instead. Your reconciliation job does exactly this: when the server is back, run it once with a lookback that covers the whole outage. Crediting is keyed on the invoice and deposit id, so running it again is safe.

The payments themselves are not lost while your endpoint is down. A paid invoice is credited to your Crypto Merchant balance on confirmation, whether or not your server heard about it. What you are recovering is your own record of it.

A production checklist

CheckWhy
Signature checked on the raw body, before parsingParsing and re-encoding JSON changes the bytes and breaks the signature
Reply within 10 seconds, with work done in a background jobA slow reply counts as a failure and causes a duplicate
Unique key on event idStops repeated deliveries
Unique key on invoice id and deposit id for creditsStops double credits from any source
Invoice fetched before granting anything valuableThe current status beats the order events arrived in
Scheduled reconciliation, at least dailyFinds anything the webhooks missed
Alert when events stay unprocessedA failing background job looks like success, because the webhook still got its 200
whsec_ secret and API key stored on the server onlyAnyone with the secret can forge events

To test failure handling, use Send test event under Developers and make your endpoint reply 500 once. Then check the deliveries list and confirm your handler treats the retry as the same event. If confirmations look slow, check network status first.

More detail: webhooks docs, the API reference, underpaid and overpaid payments and pricing. Crypto Merchant is custodial; see custodial vs non-custodial.

Frequently asked questions

How many times does Crypto Merchant retry a webhook?
Up to eight times over about two days, after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours.
What counts as a successful webhook delivery?
Any 2xx status within 10 seconds. Any other status, a timeout or a connection error counts as a failure.
Which ID should I use to skip duplicates?
The event id in the body, which is also in the CM-Event-Id header. For crediting, also use a unique key on the invoice or deposit id.
Are webhooks enough on their own?
No. Use webhooks for speed and the API as the record: fetch the invoice before granting anything valuable, and reconcile on a schedule.
Can I fulfil an order on invoice.confirming?
It is safer not to. That event means a payment was seen, not that it is final. Fulfil on invoice.paid, and credit sticky wallet deposits on deposit.confirmed.
Is Crypto Merchant custodial?
Yes. Crypto Merchant holds the keys to the deposit addresses and your pool wallet, and moves coins on your instructions.

Start accepting crypto today

Free setup, no documents to sign up, 1% per paid invoice, and payouts whenever you want.