
Charge In-Person with Mercado Pago Point from Your Own System: The Orders API (Node, 2026)
Put the terminal in PDV mode, then POST /v1/orders with type "point", config.point.terminal_id ({type}__{serial}), the amount as a two-decimal STRING, and a mandatory X-Idempotency-Key header. The order auto-loads to the terminal, the buyer pays, and you learn the result from the order.processed webhook—never from polling.
How do you charge an in-person payment with Mercado Pago Point from your own system?
Put the terminal in PDV mode, then POST /v1/orders with type set to "point", config.point.terminal_id in the {type}__{serial} format, the amount as a two-decimal STRING, and a mandatory X-Idempotency-Key header. The order auto-loads onto the physical terminal, the buyer taps, inserts, or swipes their card, and you learn the result from the order.processed webhook — never from polling. That is the entire loop: your backend creates the order, the terminal executes it, and a webhook tells you what happened.
This is the in-person build to sit next to the three online Mercado Pago posts on this site. If you’ve already wired Checkout Pro and webhooks, the mental model transfers cleanly — the redirect-is-UX, webhook-is-truth discipline is identical. What changes is the hardware in the loop and one API surface: the Orders API.
What Mercado Pago Point is (and why your server drives the terminal)
Point is Mercado Pago’s card terminal — the physical card machine — for in-person payments. The integration model is backend-driven: you do not push a payment from the terminal’s screen. Your server creates an order through the Mercado Pago API, that order auto-loads onto the terminal, the buyer taps or inserts their card, and you learn the outcome via a webhook.
The terminal is the executor; your backend is the driver. Your point-of-sale software — a web POS, a mobile app, a kiosk — holds the amount and the business logic, and the terminal only handles the card and the network to Mercado Pago’s processor. You never trust the terminal’s local state as truth; you trust the webhook, exactly like you would online. The webhooks vs polling vs API argument applies here without a single change.
The backend-driven Point flow
The #1 gotcha: the terminal must be in PDV mode
The most common “my order never reaches the terminal” bug has nothing to do with your code. The terminal must be in PDV (integrated / point-of-sale) operating mode to receive API-driven orders. In Standalone mode the terminal ignores every order you create — the POST /v1/orders call succeeds, you get an ORD-prefixed id back, and then nothing happens on the device. That silence sends people hunting through their request bodies for hours. It’s the operating mode.
List your terminals with GET /terminals/v1/list (query params limit, offset, store_id, pos_id). The response carries an operating_mode field per terminal.
curl -X GET "https://api.mercadopago.com/terminals/v1/list?limit=50&offset=0" \
-H "Authorization: Bearer $MP_ACCESS_TOKEN"
If a terminal you intend to drive from the API shows a non-PDV mode, flip it to PDV via the terminals operating-mode update endpoint before you send any orders. Do this once per device during provisioning and the whole class of “the order vanished” tickets disappears.
Create the in-person order: POST /v1/orders, field by field
Here is the full call in Node. It uses a raw fetch so every header and field is visible — no SDK magic hiding the two things that trip people up (the amount type and the terminal id format).
import { randomUUID } from "node:crypto";
const MP_ACCESS_TOKEN = process.env.MP_ACCESS_TOKEN;
export async function createPointOrder({ amount, terminalId, externalReference, description }) {
const res = await fetch("https://api.mercadopago.com/v1/orders", {
method: "POST",
headers: {
"Authorization": `Bearer ${MP_ACCESS_TOKEN}`,
"Content-Type": "application/json",
"X-Idempotency-Key": randomUUID(), // MANDATORY
},
body: JSON.stringify({
type: "point",
external_reference: externalReference, // your id, max 64 chars, unique, NO PII
description, // up to 150 chars
transactions: {
payments: [
{ amount }, // amount is a STRING with two decimals, e.g. "150.00"
],
},
config: {
point: {
terminal_id: terminalId, // "{terminal_type}__{terminal_serial}"
print_on_terminal: "seller_ticket", // or "no_ticket"
},
},
expiration_time: "PT15M", // ISO 8601 duration; default PT15M; range PT30S..PT3H
}),
});
if (!res.ok) {
throw new Error(`Order create failed: ${res.status} ${await res.text()}`);
}
return res.json(); // { id: "ORD...", status: "created", ... }
}
Field by field:
type—"point". This is what routes the order to a physical terminal instead of an online flow.external_reference— your own id (an invoice number, a cart id). Max 64 chars, must be unique, and must contain no PII. This is your reconciliation key.transactions.payments[].amount— the amount as a STRING with two decimals, e.g."150.00". More on why below.config.point.terminal_id— the target terminal in{terminal_type}__{terminal_serial}format.description— up to 150 chars, shown on the ticket.config.point.print_on_terminal—"seller_ticket"to print, or"no_ticket"to suppress.expiration_time— optional ISO 8601 duration. Default"PT15M"; valid range"PT30S"to"PT3H". Past this, the order goesexpired.
The response returns an order id that is alphanumeric with an ORD prefix. Store it against your external_reference — you’ll match on it when the webhook lands.
The terminal_id format, the amount as a STRING, and the mandatory X-Idempotency-Key
These three details cause more failed integrations than the rest of the API combined.
terminal_id is {terminal_type}__{terminal_serial} — with a double underscore. For example, INGENICO_MOVE2500__ING-23976989. It is not the serial alone and not a UUID. You get both halves from GET /terminals/v1/list; concatenate them with two underscores. A single underscore or the bare serial gets your order rejected or silently misrouted.
The amount is a STRING, not a number — "150.00", with exactly two decimals. Send the integer 150 or the float 150.0 and you’ll get a validation error or the wrong charge. Format it on the way out:
const amount = (amountInMxn).toFixed(2); // 150 -> "150.00"
X-Idempotency-Key is mandatory. Every POST /v1/orders needs one. Generate a fresh UUID per logical order attempt and reuse it if you retry that same attempt after a network hiccup — that’s what stops a dropped-connection retry from charging the customer twice. Miss the header and the request is rejected.
Those are the three credentials that trip everyone up: the Access Token (test in dev, the production token live), the terminal_id (exact {type}__{serial}), and the webhook secret — covered below, generated per application, a different value than your Access Token, and the one people forget exists.
The three fields that break the create call
The order status lifecycle: created to at_terminal to processed / failed / action_required
An order moves through a defined set of states. Knowing them lets you build a sane POS UI instead of guessing.
created— the order exists in Mercado Pago’s system.at_terminal— the order reached the physical device and is waiting for the buyer.processed— the payment was approved. Money moved.failed— the payment was rejected.action_required— the order needs terminal or customer confirmation to proceed.expired— the order timed out (past yourexpiration_time).canceled— the order was canceled.refunded— set after a refund.
The happy path is created → at_terminal → processed. Everything else is a branch your UI should handle explicitly — don’t build a spinner that only knows “waiting” and “done.” action_required, expired, and failed are all real outcomes a cashier hits on a busy day.
Order status lifecycle
- createdOrder exists in MP
- at_terminalReached the device, waiting for the buyer
- processedApproved — money moved
- failed / action_requiredRejected, or needs terminal/customer confirmation
- expired / canceled / refundedTimed out, canceled, or refunded after the fact
Canceling an order (and the x-allow-cancelable-status header for at_terminal)
To cancel, POST /v1/orders/{order_id}/cancel. It needs an X-Idempotency-Key like any mutating call.
The catch: if the order has already reached the terminal (status at_terminal), a plain cancel won’t do it. You must also send the header x-allow-cancelable-status: at_terminal to explicitly opt in to canceling an order that’s already sitting on the device waiting for the buyer.
export async function cancelPointOrder(orderId, allowAtTerminal = false) {
const headers = {
"Authorization": `Bearer ${MP_ACCESS_TOKEN}`,
"X-Idempotency-Key": randomUUID(),
};
if (allowAtTerminal) {
headers["x-allow-cancelable-status"] = "at_terminal";
}
const res = await fetch(
`https://api.mercadopago.com/v1/orders/${orderId}/cancel`,
{ method: "POST", headers }
);
if (!res.ok) throw new Error(`Cancel failed: ${res.status} ${await res.text()}`);
return res.json();
}
Pass allowAtTerminal = true when your cashier hits “cancel” on an order the customer hasn’t paid yet. Without that header, Mercado Pago protects you from accidentally yanking an order out from under a buyer mid-tap.
The webhook is the source of truth: order.processed and how to validate x-signature
Never poll for the result. Register a webhook and treat it as the source of truth. Mercado Pago sends an HTTPS POST with "type": "order" and events including order.processed, order.action_required, order.failed, order.expired, order.canceled, and order.refunded. The payload carries action, api_version, application_id, data (the order id, status, total_paid_amount, transactions), date_created, live_mode, type, and user_id.
Two rules make or break this endpoint.
The 22-second rule. Your endpoint MUST return HTTP 200 or 201 within 22 seconds, or Mercado Pago treats the notification as not delivered and retries every 15 minutes (after the third attempt the interval stretches, but retries continue). So ACK immediately and process asynchronously — never do CFDI generation or a database write on the hot path before you’ve replied.
x-signature validation — the “works in test, fails in prod” bug. Mercado Pago sends an x-signature header shaped ts=<ms>,v1=<hex> plus an x-request-id header. Build the manifest from the data.id (the query param, lowercased), the x-request-id, and the ts; compute HMAC-SHA256 with your application’s webhook secret; and compare it to v1.
import crypto from "node:crypto";
const WEBHOOK_SECRET = process.env.MP_WEBHOOK_SECRET;
export function verifySignature(req) {
const sig = req.headers["x-signature"]; // "ts=1700000000000,v1=abc123..."
const requestId = req.headers["x-request-id"];
const dataId = String(req.query["data.id"] || "").toLowerCase();
const parts = Object.fromEntries(sig.split(",").map((p) => p.split("=")));
const ts = parts.ts;
const v1 = parts.v1;
const manifest = `id:${dataId};request-id:${requestId};ts:${ts};`;
const hmac = crypto.createHmac("sha256", WEBHOOK_SECRET).update(manifest).digest("hex");
return crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(v1));
}
// Handler: ACK fast, process async.
export async function pointWebhook(req, res) {
if (!verifySignature(req)) return res.sendStatus(401);
res.sendStatus(200); // reply within 22s
queue.enqueue(req.body); // do the real work off the hot path
}
The official Mercado Pago SDKs implement this check for you; if you roll your own, the manifest order and the lowercasing are the parts that quietly differ between test and prod. The failure mode is identical to Stripe’s — I walk through the same class of bug in Stripe webhook signature verification failed, and if your Point webhooks aren’t arriving at all, the Point webhooks debug guide is the sibling to this post.
Migrating from the legacy Payment Intents API: what actually changed
If you’re on the older Payment Intents API (POST /point/integration-api/devices/{device_id}/payment-intents), the Orders API supersedes it. Everything below is a real breaking change, not a rename you can ignore — build new work on Orders.
Payment Intents API vs the Orders API
Legacy Payment Intents
- Terminal id lived in the URL path
- Amount was an integer
- state field
- Order id was a UUID
- x-test-scope header for tests
- No dedicated refund endpoint
- X-Idempotency-Key optional
Orders API (current)
- terminal id moved to config.point.terminal_id
- Amount is a decimal STRING
- status field, with more values
- Order id is an ORD-prefixed string
- x-test-scope header removed
- Dedicated refund endpoint added
- X-Idempotency-Key mandatory
Wiring it into your stack: from card tap to CFDI invoice
The last mile in Mexico is fiscal. Once your order.processed webhook fires and the signature checks out, you have an approved in-person sale — and if the customer wants a factura, you have everything you need to trigger a CFDI: the amount from total_paid_amount, your external_reference to tie it to the sale, and the order id for the audit trail. The same auto-invoice CFDI pipeline you’d hang off a Stripe or online Mercado Pago webhook hangs off this one identically — only the trigger event changes.
Point is one rail among several. Whether you also run Clip or online Mercado Pago, keeping reconciliation sane across rails is its own discipline — see multi-rail payment reconciliation. It all lives in the LATAM payments hub; if you’re still choosing which gateways to run, start with payment gateways in Mexico.
Sources — verify anything you build against the official docs, never memory: