
Mercado Pago Point webhooks not arriving? The 3 real causes and how to debug them (Node, 2026)
It is almost always one of three: the terminal is not in PDV mode (no order means no webhook), your endpoint did not return HTTP 200 or 201 within 22 seconds (MP marks it undelivered and retries every 15 minutes), or your x-signature is built wrong (works in test, fails in prod). Webhooks are the source of truth, not polling.
Why aren’t Mercado Pago Point notifications arriving? (the 3 real causes)
It is almost always one of three things. Either the terminal is not in PDV mode (no order ever runs on the device, so no webhook is ever generated), or your endpoint did not return HTTP 200 or 201 within 22 seconds (Mercado Pago marks the notification undelivered and retries every 15 minutes), or your x-signature is built wrong (it works against the simulator and then silently fails in production). Webhooks are the source of truth for Point payments, not polling — so when they go missing, your whole in-person flow goes dark. This guide walks each cause with the exact Node checks to confirm and fix it.
If you have not built the charge side yet, start with the sibling on how to charge with MP Point via the Orders API — this post assumes the order already reaches the terminal.
Webhooks are the source of truth, not polling: stop hitting the order GET
The most common anti-pattern is polling GET /v1/orders/{order_id} in a loop while the buyer taps their card. Don’t. Mercado Pago drives the whole in-person lifecycle through webhooks: your backend creates the order, the terminal auto-loads it, the buyer pays, and Mercado Pago pushes an HTTPS POST with "type": "order" to your registered URL. That push carries the final status — you never had to ask.
Polling is worse than redundant here: it hides the real bug. If your webhook endpoint is broken, polling papers over it and you ship a fragile flow that breaks the moment traffic scales. The webhooks vs polling vs API breakdown covers why an event-driven design is the correct default for payments. For Point specifically, treat the webhook as authoritative and use the order GET only as a manual reconciliation fallback, never as your primary signal.
The 22-second rule: return 200 or 201 and process async (the fast-ack pattern)
Your endpoint must return HTTP 200 or 201 within 22 seconds, or Mercado Pago treats the notification as not delivered. This is the single biggest reason webhooks “don’t arrive” — they did arrive, your handler just took too long (a slow DB write, a CFDI call, a blocking third-party request) and MP timed out.
The fix is the fast-ack pattern: validate the signature, acknowledge immediately, and do the real work after you have already responded. Never run business logic on the request thread.
import express from "express";
const app = express();
// Keep the RAW body — you need it verbatim for signature checks later.
app.post(
"/webhooks/mp-point",
express.json({ verify: (req, _res, buf) => (req.rawBody = buf) }),
(req, res) => {
// 1) Verify signature FIRST (see the x-signature section below).
if (!isValidSignature(req)) {
return res.status(401).send("invalid signature");
}
// 2) ACK within the 22-second window. Do NOT await heavy work here.
res.status(200).send("ok");
// 3) Process asynchronously, off the request thread.
setImmediate(() => {
processOrderEvent(req.body).catch((err) =>
console.error("point webhook processing failed", err)
);
});
}
);
The key line is that res.status(200).send("ok") runs before processOrderEvent. In a real system you would push the event onto a queue (SQS, Redis, BullMQ) and let a worker drain it — but even setImmediate is enough to escape the 22-second window. This is the same discipline Stripe demands; see Stripe webhook signature verification failed for the parallel case.
The 15-minute retry cadence: what MP does when your endpoint fails
When your endpoint does not return 200 or 201 in time, Mercado Pago does not give up — it retries every 15 minutes. After the third attempt the interval stretches, but retries continue. That has two consequences you must design for.
First, a “missing” webhook is often just a delayed one sitting in the retry queue because your first response was slow or errored. Check your endpoint’s uptime and latency before assuming MP never sent anything. Second — and this is the trap — retries mean at-least-once delivery. The same order.processed event can land two, three, or more times. If your handler is not idempotent, you double-count revenue, double-fulfill, or double-invoice. We solve that in the idempotency section below.
- Attempt 1 fails or times out (>22s)MP marks the notification undelivered
- Retry every 15 minutesSame event, same order id, resent
- After the 3rd attemptInterval stretches but retries continue
- Your jobFix the endpoint, then dedupe the flood of repeats
Which events you actually receive (type: order) and what the payload carries
For Point via the Orders API, every notification has "type": "order". The action field tells you what happened. You will see order.processed (approved), order.action_required (needs terminal or customer confirmation), order.failed (rejected), order.expired (timed out), order.canceled, and order.refunded.
The payload’s top-level fields are action, api_version, application_id, data, date_created, live_mode, type, and user_id. The data object carries the order id (the ORD-prefixed string), the status, the total_paid_amount, and the transactions. A trimmed order.processed body looks like this:
{
"action": "order.processed",
"type": "order",
"api_version": "v1",
"application_id": "1234567890",
"user_id": "987654321",
"live_mode": true,
"date_created": "2026-07-06T18:32:11.000-06:00",
"data": {
"id": "ORD01J9ABCDEF...",
"status": "processed",
"total_paid_amount": "150.00",
"transactions": { "payments": [ { "amount": "150.00" } ] }
}
}
Branch your worker on action (or on data.status), and route accordingly:
async function processOrderEvent(evt) {
const { action, data } = evt;
switch (action) {
case "order.processed":
return fulfill(data.id, data.total_paid_amount);
case "order.failed":
case "order.expired":
case "order.canceled":
return releaseHold(data.id);
case "order.action_required":
return notifyCashier(data.id);
case "order.refunded":
return reverseFulfillment(data.id);
default:
console.warn("unhandled point action", action);
}
}
The order status lifecycle behind these events runs created → at_terminal → processed (or failed, or action_required), plus expired and canceled, and refunded after a refund.
The #1 prod-only bug: x-signature built wrong (ts=,v1= + x-request-id + data.id → HMAC-SHA256)
Here is the one that ships to production and then breaks. Mercado Pago sends an x-signature header shaped ts=<ms>,v1=<hex> alongside an x-request-id header. You rebuild a manifest from three inputs — the data.id query parameter (lowercased), the x-request-id, and the ts — compute HMAC-SHA256 with your application’s webhook secret, and compare the result to the v1 value.
If any piece is off — wrong order, wrong secret, or you skip the check entirely in dev — it “works” against the simulator and dies in prod, because the secret is generated per application only after you configure the webhook URL and events. Test credentials often skip validation; production does not.
import crypto from "node:crypto";
function isValidSignature(req) {
const signature = req.headers["x-signature"]; // "ts=1720000000000,v1=abc123..."
const requestId = req.headers["x-request-id"];
const dataId = String(req.query["data.id"] || "").toLowerCase();
if (!signature || !requestId || !dataId) return false;
// Parse "ts=...,v1=..." into parts.
const parts = Object.fromEntries(
signature.split(",").map((kv) => kv.split("=").map((s) => s.trim()))
);
const ts = parts.ts;
const v1 = parts.v1;
if (!ts || !v1) return false;
// Build the manifest EXACTLY in this order.
const manifest = `id:${dataId};request-id:${requestId};ts:${ts};`;
const expected = crypto
.createHmac("sha256", process.env.MP_WEBHOOK_SECRET)
.update(manifest)
.digest("hex");
// Constant-time compare to avoid timing leaks.
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(v1, "hex")
);
}
Two failure modes to watch: the data.id comes from the query string (?data.id=...), not the JSON body — so make sure your framework exposes req.query["data.id"]; and crypto.timingSafeEqual throws if the buffers differ in length, so guard against a malformed v1. The official SDKs implement this exact check — if you would rather not hand-roll it, lean on them. The contrast with Stripe’s signature scheme is instructive: same HMAC-SHA256 idea, different manifest shape, and mixing them up is a classic copy-paste bug.
The PDV-mode gotcha: no order on the terminal means no webhook exists
If the webhook code is perfect but nothing ever arrives, the order probably never ran on the terminal — and no order means no webhook. The terminal must be in PDV (integrated / point-of-sale) operating mode to accept API-driven orders. In Standalone mode it silently ignores your POST /v1/orders, so nothing is created and nothing is ever pushed to you.
Confirm the mode by listing your terminals and reading the operating_mode field:
curl -X GET "https://api.mercadopago.com/terminals/v1/list?limit=50&offset=0" \
-H "Authorization: Bearer $MP_ACCESS_TOKEN"
{
"data": {
"terminals": [
{
"id": "INGENICO_MOVE2500__ING-23976989",
"operating_mode": "PDV"
}
]
}
}
If operating_mode is not PDV, flip it via the terminals operating-mode update endpoint before you debug anything else. This is the #1 “my order never reaches the terminal” cause, and because it produces zero webhooks it is easy to misdiagnose as a webhook problem. It is not — it is a device-state problem.
Idempotency on your side: dedupe by order id because retries create duplicates
Because MP retries every 15 minutes and delivery is at-least-once, you will receive the same event more than once. Idempotency is not optional. Dedupe by the order id — the ORD-prefixed string in data.id — and make the very first thing your worker does a “have I seen this already?” check.
async function processOrderEvent(evt) {
const orderId = evt.data.id;
const action = evt.action;
const dedupeKey = `${orderId}:${action}`;
// Atomic insert-or-skip. If the row already exists, we've handled it.
const firstTime = await db.insertIfAbsent("processed_events", {
key: dedupeKey,
seen_at: new Date(),
});
if (!firstTime) {
console.log("duplicate webhook ignored", dedupeKey);
return;
}
// ...safe to process exactly once below...
}
Key it on orderId:action, not just orderId, so a legitimate order.processed followed later by order.refunded both get through while pure retries of the same action are dropped. The atomic insert (a unique constraint on key, or INSERT ... ON CONFLICT DO NOTHING) is what makes this race-safe under concurrent retries. If you run multiple payment rails, the multi-rail reconciliation guide shows how this per-event dedupe feeds a clean ledger, and auto-invoicing CFDI from Mercado Pago depends on it so you never double-invoice.
How to test it: the notifications simulator before you touch real money
Do not debug against live card taps. Mercado Pago’s developer dashboard ships a notifications simulator that fires a real webhook POST at your URL with a chosen event and data.id. Use it to prove three things in order: your endpoint is reachable over HTTPS, it returns 200 or 201 well inside 22 seconds, and your x-signature check passes with the production secret.
A tunnel like ngrok is enough for local testing:
ngrok http 3000
# then register the https URL + "order" events in the MP dashboard,
# copy the generated webhook secret into MP_WEBHOOK_SECRET,
# and fire the simulator.
Only after the simulator is green should you run a real terminal charge. If you want the full charge-and-confirm loop, the MP Point Orders API walkthrough covers order creation end to end, and the general how to integrate Mercado Pago guide covers the online Checkout side.
Debug checklist: from “nothing arrives” to “order.processed confirmed”
Run these in order — each rules out one of the three real causes.
- Terminal mode.
GET /terminals/v1/listand confirmoperating_modeisPDV. If not, no order runs and no webhook exists — fix this first. - Order actually created. Confirm your
POST /v1/ordersreturned anORD-prefixed id. No order id means the problem is upstream of webhooks entirely. - Endpoint speed. Confirm you return 200 or 201 within 22 seconds. Move all heavy work to
setImmediateor a queue. - Signature. Rebuild the manifest
id:...;request-id:...;ts:...;from the querydata.id(lowercased),x-request-id, andts; HMAC-SHA256 with the application webhook secret; compare tov1. - Registration. Confirm the webhook URL and the
orderevents are registered for the correct application, and that the secret inMP_WEBHOOK_SECRETmatches that application. - Idempotency. Once events flow, confirm duplicates are deduped by
data.idso retries don’t masquerade as new payments.
FAQ about Mercado Pago Point webhooks that don’t arrive
Why do webhooks work in test but fail in production? Almost always the x-signature check. The webhook secret is generated per application after you configure the URL and events, so a test-credential secret won’t validate production signatures. Rebuild the manifest and use the production secret.
How long do I have to respond? 22 seconds. Return HTTP 200 or 201 inside that window or MP marks the notification undelivered and retries every 15 minutes.
Why am I getting the same event multiple times? At-least-once delivery plus the 15-minute retry cadence. Dedupe by the order id in data.id.
My order never reaches the terminal — is that a webhook bug? No. Check operating_mode via GET /terminals/v1/list; if it isn’t PDV, the terminal ignores API orders and no webhook is ever created.
Should I poll GET /v1/orders/{order_id} as a backup? Only as a manual reconciliation fallback, never as your primary signal. Webhooks are the source of truth. See webhooks vs polling vs API.
Sources: MP Point overview, Point payment processing, and the official point-android_integration demo repo.