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.
| Attempt | Wait after the previous failure | Time since the first attempt |
|---|---|---|
| First delivery | None | 0 |
| Retry 1 | 1 minute | 1 minute |
| Retry 2 | 5 minutes | 6 minutes |
| Retry 3 | 15 minutes | 21 minutes |
| Retry 4 | 1 hour | 1 h 21 min |
| Retry 5 | 3 hours | 4 h 21 min |
| Retry 6 | 6 hours | 10 h 21 min |
| Retry 7 | 12 hours | 22 h 21 min |
| Retry 8 | 24 hours | 46 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.confirmingmust 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.
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:
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:
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.
| Event | What it means | What your app should do |
|---|---|---|
invoice.confirming | A payment was seen and is waiting for confirmations | Show “payment received, confirming”. Grant nothing |
invoice.paid | Paid and confirmed | Fetch the invoice, then grant access or credit the account, once |
invoice.underpaid | Confirmed, but short by more than your tolerance | Show “payment incomplete”. The customer can pay the rest or ask for a refund |
invoice.expired | Time ran out before any payment was seen | Show “expired” and offer a new invoice, but keep the order open (see below) |
invoice.cancelled | The invoice was cancelled | Close the order |
invoice.refunded | Coins went back to the customer. Sent for each refund claim | Read refunded_amount on the invoice, and adjust access if your policy requires it |
deposit.confirmed | A sticky wallet deposit is final | Credit that customer, once per deposit id |
deposit.dropped | A deposit that was seen never confirmed | Remove 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.
// 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.
// 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.
| Field | What it tells you | Common mistake |
|---|---|---|
status | The current state of the invoice | Using the last webhook you saw instead |
amount, currency, amount_usd | The price you asked for | Comparing the price directly with coins received |
pay_amount | The exact amount the customer was asked to send, in the coin | Forgetting that it includes network_fee_amount |
network_fee_amount, network_fee_usd | The gas fee the customer pays on top of the price for moving this payment | Booking it as your revenue |
received_amount, received_usd | What actually arrived | Treating all of it as yours |
overpaid_amount | Extra sent above the invoice. It is held for the customer, who withdraws it from the invoice page, and isn't added to your balance | Counting it as income |
refunded_amount | The total refunded through refund links | Adding up refund events yourself |
coin, requested_coin | The coin paid, and the coin asked for if they differ | Assuming the paid coin is the one you requested |
order_id, metadata | Your own references, returned unchanged | Not 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
| Check | Why |
|---|---|
| Signature checked on the raw body, before parsing | Parsing and re-encoding JSON changes the bytes and breaks the signature |
| Reply within 10 seconds, with work done in a background job | A slow reply counts as a failure and causes a duplicate |
Unique key on event id | Stops repeated deliveries |
Unique key on invoice id and deposit id for credits | Stops double credits from any source |
| Invoice fetched before granting anything valuable | The current status beats the order events arrived in |
| Scheduled reconciliation, at least daily | Finds anything the webhooks missed |
| Alert when events stay unprocessed | A failing background job looks like success, because the webhook still got its 200 |
whsec_ secret and API key stored on the server only | Anyone 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.