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.
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 approved → invoice.paid and
rejected/cancelled → invoice.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.