Cobra en persona con Mercado Pago Point desde tu propio sistema: la Orders API (Node, 2026) — Cesar Ayala
← Todos los artículos

Cobra en persona con Mercado Pago Point desde tu propio sistema: la Orders API (Node, 2026)

Pon la terminal en modo PDV, luego haz POST /v1/orders con type "point", config.point.terminal_id ({type}__{serial}), el monto como STRING de dos decimales y el header obligatorio X-Idempotency-Key. La orden se carga sola en la terminal, el comprador paga y el resultado te llega por el webhook order.processed—nunca por polling.

¿Cómo cobro un pago presencial con Mercado Pago Point desde mi propio sistema?

Pon la terminal en modo PDV (integrado), luego haz POST /v1/orders con type en "point", el campo config.point.terminal_id en formato {terminal_type}__{terminal_serial}, el monto como un STRING de dos decimales y el header obligatorio X-Idempotency-Key. La orden se carga sola en la terminal física, el comprador acerca o inserta su tarjeta, y el resultado te llega por el webhook order.processed, nunca por polling. Tu servidor manda; la terminal solo ejecuta.

Escribí esto porque mis tres posts de Mercado Pago eran todos de cobro online. Este es el que faltaba: el cobro presencial, manejado desde tu backend, con la Orders API. Si vienes de integrar el cobro por internet, ya conoces la disciplina; aquí cambia el actor físico. Todo el código va en Node.

Qué es Mercado Pago Point (y por qué tu servidor maneja la terminal)

Point es la terminal de tarjeta (la “terminalita”) de Mercado Pago para pagos presenciales. Lo importante para ti como desarrollador no es cómo se ve, sino cómo se maneja: la conduces desde tu propio backend. Tu servidor crea una orden vía la API de MP, la orden se carga automáticamente en la terminal física, el comprador acerca, inserta o desliza la tarjeta, y tú te enteras del resultado por un webhook.

Ese es el modelo mental que tienes que interiorizar: la terminal no decide nada. Es un ejecutor. Tu sistema es la fuente de las órdenes y el webhook es la fuente de la verdad sobre el resultado. Es exactamente la misma filosofía asíncrona que ya defiendo para el cobro online en cómo integrar Mercado Pago, solo que ahora el “checkout” es una pieza de hardware sobre el mostrador.

La API vigente es la Orders API (POST /v1/orders), que reemplaza a la vieja Payment Intents API. Para trabajo nuevo, usa Orders. Al final del post explico qué cambió por si vienes migrando.

El modelo presencial: tu servidor crea la orden, la terminal la ejecuta

Tu servidorPOST /v1/orders con type point
MP carga la ordenSe auto-carga en la terminal en PDV
El comprador pagaAcerca, inserta o desliza la tarjeta
MP procesaAprueba o rechaza en la red
Webhook order.processedEl resultado llega a tu endpoint
Tu sistema entregaTicket, CFDI, actualizar la venta
El comprador nunca toca tu backend. La terminal es un ejecutor; el webhook trae el resultado.

El gotcha #1: la terminal debe estar en modo PDV

Este es el error que se lleva el primer día de todos: “creé la orden, MP me devolvió 200, pero la terminal ni se enteró”. La causa casi siempre es la misma. La terminal tiene que estar en modo PDV (integrado, punto de venta) para recibir órdenes por API. En modo Standalone la terminal ignora las órdenes que le mandas desde el backend, porque en ese modo opera sola.

Lista tus terminales con GET /terminals/v1/list. Acepta los query params limit, offset, store_id y pos_id. En la respuesta viene el campo operating_mode, que te dice si cada terminal está en PDV o en Standalone.

curl -X GET "https://api.mercadopago.com/terminals/v1/list?limit=50&offset=0" \
  -H "Authorization: Bearer $MP_ACCESS_TOKEN"

Si operating_mode no es PDV, cámbialo con el endpoint de actualización de modo de operación de terminales antes de intentar cobrar. Hasta que ese campo diga PDV, ninguna orden va a aparecer en la pantalla. Revisa esto primero cada vez que “la orden no llega a la terminal”; te ahorra horas.

Crear la orden en persona: POST /v1/orders, campo por campo

Con la terminal en PDV, ya puedes cobrar. La llamada es POST /v1/orders con el header Authorization: Bearer {ACCESS_TOKEN} y el header obligatorio X-Idempotency-Key. El cuerpo describe qué cobrar y en qué terminal.

// crearOrdenPoint.js — cobro presencial con la Orders API
import { randomUUID } from "node:crypto";

export async function crearOrdenPoint({ terminalId, referencia }) {
  const res = await fetch("https://api.mercadopago.com/v1/orders", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.MP_ACCESS_TOKEN}`,
      "Content-Type": "application/json",
      "X-Idempotency-Key": randomUUID(),
    },
    body: JSON.stringify({
      type: "point",
      external_reference: referencia,
      description: "Venta en mostrador — Plan Pro",
      transactions: {
        payments: [{ amount: "150.00" }],
      },
      config: {
        point: {
          terminal_id: terminalId,
          print_on_terminal: "seller_ticket",
        },
      },
      expiration_time: "PT15M",
    }),
  });

  return res.json(); // el id de la orden viene con prefijo ORD
}

Los campos, uno por uno:

  • type: "point". Esto es lo que marca la orden como presencial.
  • external_reference: tu propio id de la venta. Máximo 64 caracteres, único, y sin PII (nada de nombres, correos ni RFC).
  • transactions.payments[].amount: el monto como STRING con dos decimales, por ejemplo "150.00". No es un número; es cadena.
  • config.point.terminal_id: el id de la terminal en formato {terminal_type}__{terminal_serial} (más abajo lo detallo).
  • description: hasta 150 caracteres.
  • config.point.print_on_terminal: "seller_ticket" o "no_ticket".
  • expiration_time: opcional, duración ISO 8601. Default "PT15M", rango de "PT30S" a "PT3H".

El id de la orden que te devuelve es alfanumérico con prefijo ORD. Guárdalo; es con el que consultas o cancelas.

El formato de terminal_id, el monto como STRING y el X-Idempotency-Key

Tres detalles chiquitos tumban más integraciones que cualquier otra cosa. Vale la pena aislarlos.

El terminal_id. No es solo el serial. Es {terminal_type}__{terminal_serial}, con doble guion bajo en medio. Por ejemplo INGENICO_MOVE2500__ING-23976989. Si mandas solo el serial, MP no encuentra la terminal.

El monto como STRING. "150.00", no 150 ni 150.0. Con dos decimales, entre comillas. Este es el cambio más silencioso respecto de la API vieja y el que rompe copiar-pegar de código legacy.

El X-Idempotency-Key. Es un header obligatorio en Orders (en la API vieja no lo era). Si dos requests llevan la misma llave, MP trata la segunda como la misma operación y no cobra dos veces. Genera una llave por intento de cobro y persístela: si tu proceso se cae y reintenta, reusar la misma llave es lo que impide el doble cargo. Es la misma disciplina de idempotencia que aplico en todos los cobros; el porqué de fondo lo desgloso en webhooks vs polling vs API.

Las tres cosas que rompen POST /v1/orders

terminal_idFormato terminal_type__terminal_serial con doble guion bajo. No solo el serial.
amountSTRING de dos decimales: 150.00. No es número.
X-Idempotency-KeyHeader obligatorio. Una llave por intento; reúsala en el reintento para no cobrar doble.
modo PDVSin PDV la orden nunca aparece en la terminal, aunque la API responda 200.
Aísla estos tres antes de depurar cualquier otra cosa. Son casi todos los tickets del primer día.

El ciclo de vida del status: de created a at_terminal a processed

Una vez creada, la orden avanza por estados. Conocerlos es lo que te deja construir la UI del mostrador (el “esperando pago…”, el “aprobado”, el “rechazado, intenta de nuevo”) sin adivinar.

El flujo es: created va a at_terminal (la orden llegó al dispositivo) y de ahí a processed (aprobado) o failed (rechazado) o action_required (necesita confirmación en la terminal o del cliente). Además existen expired (se agotó el tiempo), canceled y refunded (después de un reembolso).

El ciclo de vida de una orden Point

  1. createdLa orden existe en MP, aún no en el dispositivo
  2. at_terminalYa llegó a la terminal; el comprador puede pagar
  3. processed / failedAprobado, o rechazado por la red
  4. action_requiredNecesita confirmación en la terminal o del cliente
  5. expired / canceledSe agotó el tiempo, o la cancelaste tú
  6. refundedDespués de un reembolso
Cada transición te llega por webhook. Es lo que alimenta la pantalla del cajero en tiempo real.

La clave de diseño: no consultes estos estados en un loop. Cada transición dispara un webhook. Deja que MP te empuje el cambio; tu polling solo debería existir como red de seguridad, no como mecanismo principal. Ese es justo el criterio que desarrollo en el post enlazado más arriba.

Cancelar una orden (y el header x-allow-cancelable-status)

Si el cajero se equivoca de monto o el cliente se arrepiente antes de pagar, cancelas con POST /v1/orders/{order_id}/cancel. Esta llamada también necesita X-Idempotency-Key.

Hay un detalle que confunde: si la orden ya está en at_terminal (o sea, ya se cargó en el dispositivo y está esperando la tarjeta), tienes que mandar además el header x-allow-cancelable-status: at_terminal. Sin ese header, MP no te deja cancelar una orden que ya está viva en la terminal.

curl -X POST "https://api.mercadopago.com/v1/orders/ORD01ABCDEF/cancel" \
  -H "Authorization: Bearer $MP_ACCESS_TOKEN" \
  -H "X-Idempotency-Key: $(uuidgen)" \
  -H "x-allow-cancelable-status: at_terminal"

Después de cancelar, la orden pasa a canceled y te llega el webhook order.canceled. No asumas la cancelación desde la respuesta HTTP; espera el evento igual que con todo lo demás.

El webhook es la fuente de verdad: order.processed y cómo validar el x-signature

Registra tu webhook y deja de pensar en polling. MP manda un POST HTTPS con "type": "order" y eventos como order.processed, order.action_required, order.failed, order.expired, order.canceled y order.refunded. El payload trae action, api_version, application_id, data (con el id de la orden, el status, el total_paid_amount y las transactions), date_created, live_mode, type y user_id.

Dos reglas duras, verificadas, que separan lo que “funciona en la demo” de lo que aguanta producción:

La regla de los 22 segundos. Tu endpoint DEBE responder HTTP 200 o 201 dentro de 22 segundos o MP lo cuenta como no entregado y reintenta cada 15 minutos (después del tercer intento el intervalo se estira, pero los reintentos siguen). O sea: responde 200/201 de inmediato y procesa en segundo plano. Nunca hagas el trabajo pesado (guardar en base, generar el CFDI) antes de responder.

La validación del x-signature. Este es el bug clásico de “funciona en test, truena en prod”. MP manda un header x-signature con forma ts=<ms>,v1=<hex> y un header x-request-id. Armas el manifest con el data.id (el query param, en minúsculas), el x-request-id y el ts; calculas HMAC-SHA256 con el webhook secret de tu aplicación; y comparas contra v1. El secret se genera por aplicación después de que configuras la URL del webhook y sus eventos.

// verificarFirmaPoint.js — valida x-signature antes de confiar en el evento
import crypto from "node:crypto";

export function firmaValida(req) {
  const [tsPart, v1Part] = req.headers["x-signature"].split(",");
  const ts = tsPart.split("=")[1];
  const v1 = v1Part.split("=")[1];
  const requestId = req.headers["x-request-id"];
  const dataId = String(req.query["data.id"] || "").toLowerCase();

  const manifest = `id:${dataId};request-id:${requestId};ts:${ts};`;
  const hmac = crypto
    .createHmac("sha256", process.env.MP_WEBHOOK_SECRET)
    .update(manifest)
    .digest("hex");

  return crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(v1));
}

Los SDKs oficiales ya traen este cálculo. Si prefieres delegarlo, úsalo; pero entiende el manifest, porque casi todas las fallas de firma son un data.id sin pasar a minúsculas o un secret de otra aplicación. Si los webhooks de plano no te llegan, tengo el playbook de depuración en el post hermano webhooks de MP Point que no llegan.

Las tres credenciales que confunden a todos

Casi todos los tickets de configuración se reducen a confundir tres cosas distintas que suenan parecido:

  • Access Token. El token de tu aplicación. Usa las credenciales de prueba mientras desarrollas y el token de producción para cobrar de verdad. Va en el header Authorization: Bearer.
  • El terminal_id. No es una credencial secreta, es un identificador, pero rompe igual si lo pones mal. Formato {terminal_type}__{terminal_serial}. Lo sacas de GET /terminals/v1/list.
  • El webhook secret. Es un tercer valor, distinto del Access Token. Se genera por aplicación, después de configurar la URL y los eventos del webhook. Es el que usas en el HMAC-SHA256 para validar el x-signature. Confundirlo con el Access Token es el error más común de firma.

Tres valores, tres propósitos. El Access Token autentica tus llamadas, el terminal_id dice a qué dispositivo mandar la orden, y el webhook secret prueba que el evento entrante de verdad viene de MP.

Migrar desde la vieja Payment Intents API: qué cambió de verdad

Si ya tenías Point andando con la Payment Intents API (POST /point/integration-api/devices/{device_id}/payment-intents), esto es lo que cambió al pasar a Orders, sin adornos:

  • El id de la terminal se movió del path de la URL al campo del body config.point.terminal_id.
  • El monto pasó de entero a un STRING decimal de dos decimales.
  • El campo state se renombró a status y ganó más valores.
  • El id de la orden dejó de ser un UUID y ahora es un string con prefijo ORD.
  • Se eliminó el header x-test-scope.
  • Se agregó un endpoint dedicado de reembolso.
  • El X-Idempotency-Key pasó a ser obligatorio.

Para builds dentro de la terminal (Android), el repo de demo oficial es github.com/mercadopago/point-android_integration.

Cablearlo en tu stack: del tap de la tarjeta a la factura CFDI

Ya tienes el cobro presencial de punta a punta: terminal en PDV, orden creada, webhook validado, resultado en processed. El último tramo es conectarlo a lo que tu negocio ya hace con las ventas online.

Cuando llega order.processed, respondes 200 rápido y en segundo plano usas el external_reference para cerrar la venta en tu sistema. Y como es una venta en México, muchas veces sigue la factura: dispara el CFDI desde ese mismo evento, exactamente como lo automatizo para el cobro online en facturación CFDI automática desde Stripe y Mercado Pago. Si además cobras por varios rieles (Point en mostrador, Checkout Pro en la web, SPEI), la conciliación de todo eso la cubro en conciliación de pagos multi-rail.

Checklist de producción para cobrar con Point

Modo PDVoperating_mode en PDV, verificado con GET /terminals/v1/list antes de cobrar.
IdempotenciaX-Idempotency-Key por intento; reúsala en el reintento para no duplicar el cargo.
Webhook = verdadorder.processed es el resultado real. Nada de confiar en la respuesta HTTP.
FirmaValida x-signature con HMAC-SHA256 y el webhook secret de la aplicación.
ACK rápidoResponde 200/201 en menos de 22s; el trabajo pesado va async.
Lo que separa una demo de algo que mueve dinero real sobre el mostrador.

Si estás decidiendo apenas con qué pasarela cobrar en persona, o comparando Point contra las alternativas, empieza por el panorama en pasarelas de pago en México. Point brilla cuando ya tienes sistema propio y quieres que el mostrador sea una extensión de tu backend, no una isla.

Fuentes oficiales, que debes consultar en vez de fiarte de la memoria (la API se mueve):