Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 4 min read

The three things that break every Mercado Pago Pix webhook signature

Mercado Pago has no stripe trigger. Here's what that costs you. If you've integrated Stripe, you know stripe listen --forward-to localhost:3000/api/webhook and stripe trigger payment_intent.succeeded. You test webhooks

Mercado Pago has no stripe trigger. Here's what that costs you.

If you've integrated Stripe, you know stripe listen --forward-to localhost:3000/api/webhook and stripe trigger payment_intent.succeeded. You test webhooks locally, end to end, in seconds.

If you've integrated Pix, you know the other thing: there is no equivalent. There is no pix trigger. So you do what everyone does β€” deploy to a URL, generate a real Pix, pay yourself R$5 from another bank app, and read console.log in production to find out whether your webhook handler actually works.

This post is about the three specific things that break in that setup. All three fail quietly: your endpoint returns 200, Mercado Pago records a success, and nothing ever retries.

Trap 1: data.id is in the query string, not the body

POST /api/webhook?data.id=123456&type=order

The signature is computed over data.id. Not the notification id, not the body β€” the resource id, which arrives in the query string.

If your framework hands you a parsed body and nothing else, you can never validate a signature. You need req.url. In Express that means reading the raw query, not req.body.data.id.

Trap 2: the manifest has a trailing semicolon

The header looks like this:

x-signature: ts=1704908010,v1=618c85345248dd820...

And the string you HMAC-SHA256 is:

${data.id};${x-request-id};${ts};

Three things bite here:

  • The trailing ; is part of the manifest. Drop it and every comparison fails.
  • x-request-id comes from the header, and it's absent in some test events β€” in which case it's an empty string, but the semicolons around it stay.
  • ts is in seconds, not milliseconds.
import { createHmac, timingSafeEqual } from 'node:crypto';

const manifest = `${dataId};${requestId};${ts};`;   // trailing semicolon
const expected = createHmac('sha256', secret).update(manifest).digest('hex');

const valid = timingSafeEqual(
  Buffer.from(expected, 'hex'),
  Buffer.from(v1, 'hex'),
);

Compare with timingSafeEqual, not ===. And if the two buffers differ in length, timingSafeEqual throws β€” check a.length === b.length first.

Trap 3: order notifications use type=order, not type=payment

The Orders API sends type: "order" and action: "order.processed". The legacy Payments API used payment.

If you filter on payment β€” which most tutorials from 2023 and earlier do β€” every Orders event falls through your handler, your endpoint returns 200 anyway, and Mercado Pago marks the delivery as successful. It never retries.

This is the worst of the three because nothing errors. You find out when a customer says they paid and you have no order.

The smaller ones that still cost hours

notification_url doesn't exist on Orders. Per-order callbacks fail with
400 unsupported_properties: additionalProperties '$.notification_url' not allowed.
That's a legacy field. On Orders the webhook is configured once per application,
in the panel.

total_amount alternates between string and number. "5.00" in the create
response, 5.00 inside some webhook payloads. amount.split('.') on a number throws β†’ 500 β†’ infinite retries.

The webhook already contains the full order. data isn't just an id β€” it's the whole object. No follow-up GET needed. (The legacy API did send only an id, which is why older tutorials tell you to fetch.)

paidAt doesn't exist. There's no date_approved field on the Orders webhook. If you need exact settlement time, store your own.

Idempotency, and why the retry will bite you

Providers re-deliver webhooks. If your handler processes the same event twice, a customer gets charged once and shipped twice.

The ordering rule:

parseWebhook β†’ markProcessed β†’ onEvent

Dedupe before your business logic. If you run the handler first and mark afterwards, a crash halfway through leaves a half-applied event and the retry applies it again.

The HTTP codes matter just as much:

Situation Correct response
Valid, first time 200
Duplicate 200 (not 409 β€” the provider would retry)
Invalid signature or irrelevant event 200 (not 401 β€” the provider would retry forever)
Your handler threw (transient) 500 (so it retries)

Return 4xx on something the provider thinks is retryable and you get redelivery forever. Return 5xx on something it doesn't retry and you lose the payment silently.

Testing it without paying yourself R$5

I wrote a small CLI that does one thing: sends a correctly signed Mercado Pago webhook at any URL you point it at, including localhost.

npx pix-harness send --to http://localhost:3000/api/webhook

It reproduces all three traps above β€” data.id in the query string, the
ts=...,v1=... header, the manifest with the trailing semicolon. Zero dependencies, MIT, source on npm.
I built it because I got tired of the deploy-pay-check loop.

The flag worth knowing about:

npx pix-harness send --to http://localhost:3000/api/webhook --duplicate

It delivers the same data.id twice, with the same signature. If your handler
creates two orders, you've just found your idempotency bug in two seconds instead
of in a customer's bank statement. Mercado Pago's own "Simular notificaΓ§Γ£o" panel
can't do this β€” it has no repeat/duplicate control.

Bonus: make Mercado Pago approve a Pix by itself

In the test environment, payer.first_name acts as a scenario switch:

payer.first_name Result
APRO Order created, then automatically approved
OTHE Stays pending
CANO Cancelled

Poll getCharge() and watch action_required become processed in about three
seconds. Full cycle, no scanning, no paying, no tunnel.

That's the whole map. The three traps are the part the docs don't spell out, and
now they're not going to cost you a weekend.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.