MercadoPago webhooks done right: signature verification, idempotency, and the truth-lands-later problem

A walkthrough of UseDeploy's real MercadoPago adapter — HMAC signature verification with a timing-safe compare, verify-then-fetch webhooks, and idempotent read-model writes that survive at-least-once redelivery and the MercadoPago Checkout Pro sandbox.

MC
Martin Coll

If you've done any serious MercadoPago Checkout Pro sandbox testing, you already know the shape of the pain: the sandbox is flaky enough that people end up testing against production, test users stop working for no reason, and a redirect that succeeded a minute ago fails the next time you try it. On MercadoPago's own sdk-js repo there's a discussion thread full of developers reporting exactly this — a Checkout Pro sandbox that pushes you to test in production, intermittent errors where roughly one in ten attempts works, and test users that silently break (as of July 2026, github.com/mercadopago/sdk-js discussion #62).

Most of that friction is MercadoPago's, not yours. But a chunk of it is self-inflicted, and it comes from the same place every time: the webhook. The webhook is where MercadoPago's asynchronous model bites, and it's the part a DIY integration almost always gets subtly wrong — which is why there's so much hand-rolled Next.js + MercadoPago glue code floating around (as of July 2026, no mainstream Stripe-first SaaS boilerplate ships a native MercadoPago adapter). This post walks through UseDeploy's real adapter — the actual code in apps/server/src/modules/billing/infrastructure/providers/mercado-pago-payment-provider.ts — and the three things it has to get right: signature verification, the "truth lands later" fetch, and idempotency under at-least-once redelivery.

The webhook tells you almost nothing

The first thing that trips people up is that a MercadoPago payment notification does not contain the payment. It contains a pointer to it. The body is basically a type and an id:

interface MpWebhookBody {
  readonly type?: string;
  readonly data?: { readonly id?: string };
  readonly action?: string;
}

There's no status, no amount, no external_reference — nothing you can act on. MercadoPago's model is "the truth lands later, as a signed notification": the webhook is a nudge that says something happened to payment N, go look. So the adapter's parseWebhook short-circuits anything that isn't a payment event with a usable id, and treats everything else as a no-op acknowledgement:

if (body.type !== 'payment' || !body.data?.id) {
  return ok(null);
}

Returning ok(null) here matters: it's an accepted-but-ignored signal, so the HTTP layer answers 200 and MercadoPago stops redelivering. A webhook you can't act on is not an error; treating it as one just earns you an infinite retry loop.

Verifying the signature without a timing side channel

Before the adapter trusts that id enough to spend an API call on it, it verifies the signature. MercadoPago sends two headers, x-signature and x-request-id, and the x-signature value is a comma-separated list of key=value parts — a timestamp ts and the HMAC v1. The adapter parses those out, rebuilds MercadoPago's canonical manifest string, and recomputes the HMAC-SHA256 with your webhook secret:

private verifyWebhookSignature(
  xSignature: string,
  xRequestId: string,
  dataId: string,
): boolean {
  const parts = Object.fromEntries(
    xSignature.split(',').map((part) => {
      const [key, ...val] = part.trim().split('=');
      return [key, val.join('=')];
    }),
  );

  const ts = parts['ts'];
  const hash = parts['v1'];
  if (!ts || !hash) return false;

  const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`;
  const expected = crypto
    .createHmac('sha256', this.cfg.webhookSecret)
    .update(manifest)
    .digest('hex');

  const hashBuf = Buffer.from(hash, 'utf8');
  const expectedBuf = Buffer.from(expected, 'utf8');
  if (hashBuf.length !== expectedBuf.length) return false;
  return crypto.timingSafeEqual(hashBuf, expectedBuf);
}

Two details are load-bearing. First, the manifest template is exact — id:{dataId};request-id:{xRequestId};ts:{ts}; — including the trailing semicolons. Get one separator wrong and every signature fails, which is a great way to spend an afternoon in the sandbox convinced your secret is wrong.

Second, the comparison is crypto.timingSafeEqual, not ===. A naive string compare returns as soon as two bytes differ, and that timing difference leaks how much of the signature you got right — enough, in principle, to forge one byte at a time. timingSafeEqual compares in constant time, but it throws if the two buffers are different lengths, so the adapter length-checks first and returns false on a mismatch. This is the same discipline UseDeploy uses on the way out for its own signed outgoing webhooks; if you want that side of the story, the signed outgoing webhooks post covers the HMAC and retry design in depth.

Only after the signature checks out does the adapter go fetch the real payment:

private async getPaymentStatus(
  paymentId: string,
): Promise<Result<MpPaymentResponse, DomainError>> {
  const response = await fetch(`${MP_API_BASE}/v1/payments/${paymentId}`, {
    headers: { Authorization: `Bearer ${this.cfg.accessToken}` },
    signal: AbortSignal.timeout(MP_FETCH_TIMEOUT_MS),
  });
  // ...maps to a normalized BillingEvent
}

Verify, then fetch, then translate approvedinvoice.paid and rejected/cancelledinvoice.payment_failed. The rest of the app never sees a MercadoPago payload; it sees a normalized BillingEvent that looks identical whether it came from MercadoPago, Stripe, or Polar. (That normalization is the subject of the sibling post, one billing port, three providers.)

Idempotency, because delivery is at-least-once

Here's the rule that the sandbox exists to teach you the hard way: MercadoPago will deliver the same notification more than once. Retries, at-least-once semantics, you replaying a saved request during testing — all of it means your handler for "payment N was approved" runs two, three, five times for one actual payment. If that handler is INSERT, you get duplicate receipts. If it's balance += amount, you get a support ticket.

UseDeploy handles this in two layers, and it's worth separating them because they're often conflated.

The first layer is the domain-event fanout. HandleWebhookUseCase doesn't write anything itself — it re-publishes the normalized event onto the bus and returns:

const event = parsed.value;
const aggregateId =
  'subscriptionId' in event ? event.subscriptionId : event.invoiceId;
await this.events.publish([
  {
    name: `billing.${event.type}`,
    aggregateId,
    occurredAt: new Date(),
    payload: event as unknown as Record<string, unknown>,
  },
]);

That bus is backed by a transactional outbox: the event is persisted before dispatch, so a listener that throws leaves the row pending and gets retried with backoff, and the webhook 200 is never at risk. This is deliberately at-least-once too — which pushes the actual idempotency down to the listeners where it belongs. The outbox guarantees the event arrives; it does not promise it arrives once.

The second layer is where duplicates actually die. The listener that turns a billing.invoice.paid event into a Purchase row calls a use case whose whole job is to be safe to run twice:

async createIfMissing(input: CreatePurchaseInput): Promise<Purchase> {
  // Atomic upsert keyed on the unique externalOrderId. The `update`
  // branch is intentionally a no-op so duplicate webhook deliveries
  // resolve to the original row without overwriting paidAt or metadata.
  const row = await this.prisma.purchase.upsert({
    where: { externalOrderId: input.externalOrderId },
    create: { /* ...the receipt... */ },
    update: {},
  });
  return toDomain(row);
}

The idempotency key is externalOrderId — the MercadoPago payment id — and it carries a unique constraint in the schema. The update: {} branch is empty on purpose: a redelivery resolves to the existing row without touching paidAt or metadata, so the second, third, and fifth delivery of the same payment are all harmless. The listener still throws on a genuine failure so the outbox retries — a lost Purchase row means the read model silently diverges from what the provider thinks happened, which is exactly the bug you don't want to discover during a reconciliation three weeks later.

What the adapter honestly doesn't do

The story is stronger when it's honest about its edges, so: the MercadoPago adapter ships a real Checkout Pro (Preferences API redirect) plus HMAC-verified webhooks in payment mode. It does not yet do subscriptions. Ask for a recurring checkout and it tells you so, rather than pretending:

if (input.mode === 'subscription') {
  return err(
    new InfrastructureError(
      'MercadoPago subscriptions (preapproval) are not yet supported. Use mode "payment" for one-time checkout.',
    ),
  );
}

MercadoPago's preapproval (recurring) API is on the roadmap, not in the code. Likewise the admin operations — getSubscriptionSummary, changePlan, extendTrial, cancelSubscription, listInvoices — return an adminUnsupported error for MercadoPago; they need the corresponding MP API calls wired before the dashboard's admin panel can drive them. And one-time checkout currently builds ARS preferences. None of that is hidden behind a happy-path stub that fails at runtime; it's an explicit error at the boundary, which is the difference between "not implemented" and "implemented wrong."

Why this makes sandbox testing survivable

Circle back to the sandbox pain we opened with. You can't fix MercadoPago's flaky test users, but you can make your own webhook path boring enough that the flakes don't corrupt anything. Because verification is timing-safe and the manifest is exact, a signature either validates or it doesn't — no heisenbugs from a half-right compare. Because the handler is verify-then-fetch, a duplicate nudge just re-reads the same payment and produces the same normalized event. And because the read-model write is an idempotent upsert on the payment id, you can redeliver a saved sandbox notification as many times as you like — from the MercadoPago dashboard, from a captured cURL, from a retry storm — and end up with exactly one receipt.

Point MercadoPago at the raw-body endpoint (POST /api/v1/billing/webhook, which keeps the body unparsed so the adapter can verify the signature), set PAYMENT_PROVIDER=mercado-pago with MERCADO_PAGO_ACCESS_TOKEN and MERCADO_PAGO_WEBHOOK_SECRET, tunnel it through ngrok, and the same code path you just read runs in the sandbox and in production. The full provider matrix, env vars, and read-model tables are in the billing docs, and the provider-agnostic design — one typed port, MercadoPago/Stripe/Polar behind it — is on the billing feature page. The webhook is the part of a MercadoPago integration that's genuinely hard to get right; the point of the adapter is that you only have to get it right once.