Tu webhook de Mercado Pago dice que sí y el pago queda pendiente
El error más común al integrar Checkout Pro no está en el checkout: está en confiar en lo que dice el webhook.
Integrás Checkout Pro, probás en sandbox y funciona. Sale a producción y el primer pago real queda en pending para siempre. El comprador pagó, Mercado Pago lo confirma en su panel, y tu base de datos sigue diciendo que la orden está esperando.
Casi siempre es lo mismo: el webhook llegó, tu servidor respondió 200, y nadie preguntó qué pasó de verdad.
El webhook no te dice el estado del pago
Esta es la parte que confunde. La notificación que manda Mercado Pago no trae el estado: trae un identificador y el tipo de recurso que cambió.
{
"action": "payment.updated",
"type": "payment",
"data": { "id": "123456789" }
}Eso es todo. No hay status, no hay monto, no hay comprador. Si tu handler lee el cuerpo y marca la orden como pagada, estás marcando como pagada una notificación que solo dice "el pago 123456789 cambió". Pudo cambiar a approved, a rejected o a in_process.
El webhook es un aviso de que mires, no la respuesta. El estado real se pide a la API:
GET https://api.mercadopago.com/v1/payments/123456789
Authorization: Bearer APP_USR-tu-access-tokenRecién ahí tenés status, status_detail, transaction_amount y external_reference. Y solo status === "approved" significa que el dinero está.
Validá que la notificación sea de Mercado Pago
Tu notification_url es una URL pública. Cualquiera que la descubra puede mandarle un POST con el id de un pago ajeno. Si tu handler confía en el cuerpo, acaba de regalar productos.
Mercado Pago firma cada notificación. Vienen dos cabeceras:
- x-signature — con el timestamp (ts) y el hash (v1), separados por coma.
- x-request-id — el identificador del request.
Se arma el manifest con el id del recurso, el request id y el timestamp, y se compara un HMAC SHA256 contra el v1 recibido, usando la clave secreta que da el panel al configurar el webhook:
import crypto from 'node:crypto';
const verificar = (req, secret) => {
const [tsPart, v1Part] = req.headers['x-signature'].split(',');
const ts = tsPart.split('=')[1].trim();
const firma = v1Part.split('=')[1].trim();
// El orden y los guiones importan: si el manifest no es exacto,
// el hash no coincide y vas a creer que la clave esta mal.
const manifest = `id:${req.query['data.id']};request-id:${req.headers['x-request-id']};ts:${ts};`;
const esperado = crypto.createHmac('sha256', secret).update(manifest).digest('hex');
// timingSafeEqual y no ===: comparar strings filtra informacion por
// el tiempo que tarda en fallar.
return crypto.timingSafeEqual(Buffer.from(firma), Buffer.from(esperado));
};Te van a llegar duplicados
Mercado Pago reintenta si no recibe un 2xx rápido, y además notifica cada cambio de estado del mismo pago. El mismo id te va a llegar varias veces, y a veces dos notificaciones casi simultáneas.
Si tu handler suma stock, manda un mail o acredita saldo sin controlar eso, lo hace dos veces. La defensa es guardar el id del pago con una restricción única y salir temprano si ya fue procesado:
CREATE TABLE pagos_procesados (
payment_id BIGINT PRIMARY KEY,
estado TEXT NOT NULL,
procesado_en TIMESTAMPTZ NOT NULL DEFAULT now()
);La clave primaria hace el trabajo: el segundo INSERT falla y el handler corta. Es más confiable que un SELECT previo, porque entre el SELECT y el INSERT entra la notificación duplicada.
Respondé rápido, procesá después
Mercado Pago espera un 2xx en pocos segundos. Si tu handler consulta la API, escribe en la base, manda un mail y genera un PDF antes de responder, vas a superar ese tiempo y vas a recibir reintentos — que a su vez disparan más trabajo.
El orden que funciona: validar la firma, encolar, responder 200. El procesamiento real va en un worker.
Atá el pago a TU orden con external_reference
Al crear la preference, mandá tu propio identificador de orden en external_reference. Sin eso, cuando llega el webhook tenés un payment_id de Mercado Pago y ninguna forma directa de saber a qué compra corresponde.
const preference = {
items: [{ title: 'Plan anual', quantity: 1, unit_price: 25000, currency_id: 'ARS' }],
external_reference: orden.id, // TU id, el que vas a buscar despues
notification_url: 'https://tu-dominio/api/mp/webhook',
back_urls: { success: '...', failure: '...', pending: '...' },
};Por qué el sandbox no te avisó
En sandbox los pagos se aprueban al instante con las tarjetas de prueba, así que el estado casi nunca queda intermedio. En producción aparecen los casos que rompen la integración: pagos en in_process por revisión, transferencias que acreditan más tarde, rechazos por fondos.
Probá al menos un pago en in_process antes de salir. Con las tarjetas de prueba se fuerza el estado poniendo un nombre específico en el titular — la documentación de Mercado Pago lista cuál corresponde a cada resultado.
Los estados que vas a ver, y qué hacer con cada uno
La mayoría de las integraciones tratan el pago como binario: aprobado o no. En producción aparecen estados intermedios, y cada uno pide una decisión distinta de negocio.
- approved — el dinero está. Es el único que habilita entregar.
- in_process — está en revisión. No entregues, pero tampoco canceles: puede aprobarse más tarde y el cliente ya pagó.
- pending — falta una acción del comprador, típicamente pagar un cupón. Puede tardar días.
- rejected — no se cobró. Conviene guardar el status_detail: distingue fondos insuficientes de un dato mal cargado, y eso cambia qué le decís al cliente.
- refunded / charged_back — el dinero volvió. Si tu producto ya se entregó, esto tiene que disparar algo en tu operación, no solo un registro.
El error caro es tratar in_process como rechazo. El comprador ve el pago hecho de su lado, tu sistema dice que no, y esa conversación la termina teniendo alguien por teléfono.
Qué pasa si tu servidor estuvo caído
Mercado Pago reintenta durante un tiempo, pero no para siempre. Si tu endpoint estuvo caído lo suficiente, hay pagos cuyo aviso nunca vas a recibir, y esa plata quedó cobrada con la orden abierta.
Por eso una integración seria no depende solo del webhook. Hace falta un proceso de reconciliación que, cada tanto, busque las órdenes que siguen pendientes más allá de lo razonable y pregunte por su estado a la API:
// Corre cada 15 minutos. Es la red debajo del webhook, no un reemplazo.
const reconciliar = async () => {
const abiertas = await ordenesPendientesDesdeHaceMasDe({ minutos: 20 });
for (const orden of abiertas) {
// Buscar por TU identificador, que es el unico que conoces con certeza
// cuando el aviso nunca llego.
const { results } = await mp.buscarPagos({ external_reference: orden.id });
const aprobado = results.find((p) => p.status === 'approved');
if (aprobado) await confirmar(orden, aprobado);
}
};La lista corta
- El webhook avisa; el estado se pide con GET /v1/payments/{id}.
- Validá x-signature antes de tocar la base.
- Clave única por payment_id: los duplicados llegan.
- Respondé 200 rápido y procesá en un worker.
- external_reference para atar el pago a tu orden.
- back_urls no confirma nada.
- in_process no es un rechazo.
- Reconciliá periódicamente: el webhook puede no llegar nunca.
Preguntas frecuentes
- ¿Por qué el pago aparece aprobado en Mercado Pago y pendiente en mi sistema?
- Porque tu sistema nunca confirmó contra la API. O el webhook no llegó, o llegó y el handler tomó el estado del cuerpo de la notificación —que no lo trae— en vez de pedirlo con GET /v1/payments/{id}.
- ¿Puedo confiar en el status que viene en el webhook?
- No viene ningún status. La notificación trae el tipo de recurso y su id; nada más. Cualquier decisión sobre el pago se toma después de consultar la API.
- ¿Cuántas veces me puede llegar la misma notificación?
- Varias. Mercado Pago reintenta si no recibe un 2xx a tiempo, y además notifica cada cambio de estado del mismo pago. Guardá el payment_id con una clave única y cortá si ya fue procesado.
- ¿Las back_urls confirman que se pagó?
- No. Indican a dónde vuelve el navegador del comprador, y el comprador puede cerrar la pestaña antes de volver. El pago se acredita igual. La confirmación es el webhook, verificado contra la API.
- ¿Cómo pruebo un pago pendiente sin salir a producción?
- Con las tarjetas de prueba se fuerza el resultado usando un nombre específico en el titular; la documentación de Mercado Pago lista cuál corresponde a cada estado. Probá al menos un in_process antes de publicar: es el caso que rompe la mayoría de las integraciones.
¿Tenés pagos que quedan colgados?
Reviso integraciones de Mercado Pago: el handler, la verificación contra la API, la idempotencia y el job de reconciliación que cierra lo que el webhook nunca cerró. Contame cómo lo tenés armado y te digo qué le falta.
contacto@meglioalan.com