MercanPay Blog

Webhook Retries and Idempotent Handling for Crypto Payments

The webhook retry schedule, the 10-second rule, de-duplicating repeated events and reconciling missed notifications. Handle crypto payment webhooks robustly.

Published: November 11, 20266 min readTürkçe oku
Webhook Retries and Idempotent Handling for Crypto Payments

Webhook retries make sure no payment notification is lost when your server is briefly unreachable. The trade-off is that the same event can reach you more than once. This guide explains when and how often MercanPay retries webhooks, how to process repeated events idempotently, and how to reconcile if a notification slips through.

We won't repeat signature checks here; see How to verify webhook signatures. The full event list is in Crypto payment webhook events.

When is a webhook "delivered"?

MercanPay sends a signed POST to your webhook URL. Delivery succeeds when your server:

  • answers within 10 seconds, and
  • returns a 2xx status such as 200.

A timeout, a connection error or any non-2xx answer (400, 401, 500, 502 and so on) counts as a failed attempt.

The retry schedule

After a failed attempt, the request is repeated with growing gaps:

Attempt Wait after the previous attempt
2 30 seconds
3 2 minutes
4 10 minutes
5 30 minutes
6 1 hour
7 3 hours
8 6 hours
9 12 hours
10 24 hours

If the last attempt fails too, the delivery is marked failed. A window of more than two days means maintenance or a short outage on your side doesn't turn into lost notifications.

Events only go out for committed changes

MercanPay records each webhook event in the same database transaction as the state change that caused it, a pattern called a transactional outbox. If you receive invoice.paid, the invoice really is stored as paid. No event is sent for a change that didn't commit.

It's a good model for your side too: mark the order paid and record "event handled" in one transaction.

Why the same event arrives twice

Retries guarantee at-least-once delivery, not exactly-once. Duplicates happen when:

  • your server processed the order but answered after 10 seconds, so the attempt counted as failed,
  • your response got lost on the network,
  • you resent an event manually from the delivery history,
  • you set a callback_url on the invoice. The event goes to both the Webhook URL in Settings and the callback_url, so if both point at the same system you'll see it twice.

On that last point: both requests carry the same event id (id), so de-duplicating by event id works in that case too.

Idempotent handling, step by step

1. Store the event id uniquely

CREATE TABLE processed_events (
  event_id    TEXT PRIMARY KEY,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

If the insert does nothing, you've already seen the event.

2. Make the order update conditional

Also check order state: "mark paid only if not already paid". This second guard protects you from different events that touch the same order, such as invoice.partial followed by invoice.paid.

3. Answer fast, queue the heavy work

Sending emails, generating licences or calling another API can take longer than 10 seconds. In the handler, verify the signature, record the event, queue the work and return 200. Here it is in Node.js with Express and plain SQL (MercanPay has no Node SDK; you use the REST API and a small verify function):

import crypto from 'node:crypto';
import express from 'express';

function verify(secret, rawBody, header, tolerance = 300) {
  const sig = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(sig.t);
  if (!sig.v1 || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
  const mac = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return mac.length === sig.v1.length && crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(sig.v1));
}

const app = express();
app.post('/mercanpay/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const raw = req.body.toString();
  if (!verify(process.env.MERCANPAY_WEBHOOK_SECRET, raw, req.get('X-MercanPay-Signature') || '')) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(raw);
  await db.tx(async (tx) => {
    const r = await tx.query(
      'INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING',
      [event.id],
    );
    if (r.rowCount === 1) {
      await tx.query('INSERT INTO jobs (kind, payload) VALUES ($1, $2)', [event.type, event.data]);
    }
  });
  res.sendStatus(200); // the worker does the rest
});

db and jobs stand in for your own app. What matters is the pattern: verify, record, queue, answer quickly.

4. Check amount and coin in business logic

Before fulfilling, the worker should confirm that data.invoice.order_id, token and paid_amount match your records. See Matching invoices to orders with order_id.

Reconciling missed notifications

If your server was down for more than two days, or a bug mishandled events, query the API yourself. You can also run this as a periodic job:

import os
from mercanpay import MercanPay

zp = MercanPay("https://mercanpay.com", os.environ["MERCANPAY_API_KEY"])

before = None
while True:
    page = zp.list_invoices(status="paid", limit=100, before=before)
    for inv in page["data"]:
        ensure_order_paid(inv["order_id"], inv["token"], inv["paid_amount"])  # idempotent
    before = page.get("next_before")
    if not before:
        break

ensure_order_paid should use the same conditional update as your webhook handler, so reconciliation and webhooks never double-process an order. Run the same loop with status="overpaid" as well.

How often should reconciliation run?

It depends on your volume and how time-sensitive orders are. A sensible approach:

  • For instantly delivered digital goods, the webhook is the primary channel, and a daily reconciliation run to confirm nothing slipped through is usually enough.
  • After an outage, run it once by hand as soon as your server is back, covering the downtime. Retries will arrive anyway, but you don't have to wait for them.
  • In the first days of a new integration, comparing reconciliation results with your webhook records surfaces handler bugs early.

If reconciliation finds an order that's paid on MercanPay but unpaid on your side, don't just fix it silently. Log it, because the mismatch points to a problem in your webhook handler that needs a look.

Delivery history in the panel

The Webhooks page shows every event sent: type and URL, attempts and status (delivered, pending, failed), HTTP code, response time and any error. You can resend an event there, send a test.ping with "Send test", and export the history as CSV, Excel or PDF; see Export crypto payment reports.

Common mistakes

  • Doing the work before answering: waiting on an email send pushes you past 10 seconds and triggers needless retries.
  • Returning 200 on failure: if you couldn't process an event, return 5xx so it's retried.
  • No de-duplication at all: sending the same licence twice is the classic result.
  • A redirecting URL: a URL that redirects (say HTTP to HTTPS) doesn't return 2xx. Point the webhook straight at the handler.

FAQ

Can I get a failed event again?

Yes. Resend it from the delivery history on the Webhooks page, and you can always query current invoice status through the API.

Do events arrive in order?

Don't rely on it. Retries can reorder events, so build your logic on invoice status.

Should I de-duplicate by event id or order_id?

Use both. The event id catches repeats of the same event, while the order state check catches different events for the same order.

Get started with MercanPay

Accept crypto payments with signed, retried webhooks that don't get lost: apply as a merchant and read the documentation.

#Webhook#API#Integration#Idempotency

Related posts

Start accepting crypto payments in minutes

Accept USDT and TRX (TRC20). Fees from 0.4%, a hosted payment page, API and webhooks.