MercadoPago#
gateway_id: "mercadopago". Es la pasarela con dos modos de operación y con el catálogo de
estados más amplio de las tres: MercadoPago tiene medios de pago genuinamente asíncronos (cupones de
pago en efectivo, transferencias) que pueden tardar días en resolverse, y eso se nota en cómo hay que
integrarla.
| Capacidad | MercadoPago |
|---|---|
| Flujo | Bricks en la página (IFRAME_FLOW) o preferencia con redirección |
| Captura diferida | Sí en el adaptador (SEPARATE_CAPTURE), sin endpoint público todavía |
| Reembolso parcial | Sí (PARTIAL_REFUND) |
| Webhooks nativos | Sí (NATIVE_WEBHOOKS), firmados con x-signature |
| Divisas | Las de la cuenta del tenant (CLP, ARS, BRL, MXN…) |
Los dos modos#
1. Card Payment Brick (en tu página)#
El Brick de MercadoPago renderiza el formulario de tarjeta dentro de su propio iframe, tokeniza contra
la infraestructura de MercadoPago con la public_key del tenant y devuelve un token de un solo
uso. El comprador no sale de tu sitio. Es el modo por defecto.
tienda-andina.cl
└─ iframe checkout.apipay.io <- checkout-widget
└─ iframe sdk.mercadopago.com <- Card Payment Brick: la tarjeta sólo existe aquí
2. Preferencia de checkout (con redirección)#
Se activa con la metadata reservada mercadopago_use_preference al crear el intent. El hub crea una
preferencia (POST /checkout/preferences) y el comprador va a la página de pago de MercadoPago, donde
existen medios que el Brick de tarjeta no cubre (dinero en cuenta, cupones, cuotas sin tarjeta).
Crear el intent#
import { ApiPay } from '@apipay/node';
const apipay = new ApiPay({ apiKey: process.env.APIPAY_SECRET_KEY ?? '' });
const intent = await apipay.paymentIntents.create({
amount_minor: 1499000, // CLP tiene exponente 0
currency: 'CLP',
gateway_id: 'mercadopago',
description: 'Orden 4831 - Tienda Andina',
customer_email: 'cliente@example.com',
// Necesaria para el modo preferencia: alimenta las tres back_urls.
return_url: 'https://tienda-andina.cl/checkout/retorno',
metadata: {
order_id: '4831',
// Omitir esta clave usa el Brick en la pagina; ponerla en "true" crea la preferencia.
mercadopago_use_preference: 'true',
},
});back_urls: las tres apuntan al mismo sitio#
MercadoPago exige tres URLs de retorno y ApiPay las rellena las tres con tu return_url, más
auto_return: "approved" para que el retorno sea automático cuando el pago se aprueba:
{
"back_urls": {
"success": "https://tienda-andina.cl/checkout/retorno",
"pending": "https://tienda-andina.cl/checkout/retorno",
"failure": "https://tienda-andina.cl/checkout/retorno"
},
"auto_return": "approved"
}
Además del retorno, el hub envía en la preferencia:
| Campo de MercadoPago | Valor que pone ApiPay |
|---|---|
external_reference | El id público del intent (pi_…) |
metadata.apipay_intent_id | El mismo pi_…, para búsquedas en el panel de MercadoPago |
items[0].unit_price | El monto en unidades mayores (conversión por exponente de la divisa) |
items[0].currency_id | La divisa ISO 4217 |
items[0].title | metadata.description si existe; si no, el pi_… |
Tu resto de metadata viaja tal cual, salvo la clave de control mercadopago_use_preference, que se
elimina antes de enviarla.
Estados: MercadoPago tiene más que las otras#
status de MercadoPago | Estado normalizado | Estado del intent |
|---|---|---|
pending | PENDING | PROCESSING |
in_process | PENDING | PROCESSING |
in_mediation | PENDING | PROCESSING |
authorized | AUTHORIZED | PROCESSING |
approved | SUCCEEDED | SUCCEEDED |
rejected | FAILED | FAILED |
cancelled | CANCELED | CANCELED |
expired | EXPIRED | EXPIRED |
refunded | SUCCEEDED | sin cambio |
charged_back | SUCCEEDED | sin cambio |
| cualquier otro | PENDING | PROCESSING |
in_mediation merece un párrafo propio: es una disputa abierta en MercadoPago. Se proyecta como
PROCESSING porque no es terminal y puede resolverse en cualquiera de los dos
sentidos. No lo trates como un fallo ni devuelvas la mercadería.
Rechazos: status_detail#
status_detail | code de ApiPay | gateway_error_code |
|---|---|---|
cc_rejected_insufficient_amount | card_declined | INSUFFICIENT_FUNDS |
cc_rejected_bad_filled_card_number | card_declined | CARD_DECLINED |
cc_rejected_bad_filled_security_code | card_declined | CARD_DECLINED |
cc_rejected_call_for_authorize | card_declined | CARD_DECLINED |
cc_rejected_other_reason | card_declined | CARD_DECLINED |
cc_rejected_bad_filled_date | card_declined | EXPIRED_CARD |
cc_rejected_card_expired | card_declined | EXPIRED_CARD |
cc_rejected_high_risk | gateway_rejected | FRAUD_SUSPECTED |
cc_rejected_blacklist | gateway_rejected | FRAUD_SUSPECTED |
cc_rejected_card_disabled | gateway_rejected | FRAUD_SUSPECTED |
| cualquier otro | gateway_rejected | UNKNOWN |
El status_detail crudo siempre viaja en gateway_raw_code para soporte:
{
"type": "https://api.apipay.io/problems/card-declined",
"title": "Payment Required",
"status": 402,
"detail": "mercadopago rejected the payment",
"code": "card_declined",
"gateway_id": "mercadopago",
"gateway_error_code": "INSUFFICIENT_FUNDS",
"gateway_raw_code": "cc_rejected_insufficient_amount"
}
cc_rejected_bad_filled_card_number y cc_rejected_bad_filled_security_code son errores de tecleo,
no rechazos del emisor: el mensaje correcto para el comprador es "revisa los datos", no "usa otra
tarjeta". Distínguelos por gateway_raw_code.
Webhooks de MercadoPago hacia ApiPay#
No tienes que implementarlos, pero explican de dónde sale el estado final de un pago asíncrono:
-
Endpoint:
POST /v1/gateways/mercadopago/webhooks, público y sin API key. -
Cabecera:
x-signature, con partests(segundos unix) yv1(HMAC hex), másx-request-id. -
El manifiesto que se firma no es el cuerpo, a diferencia de ApiPay:
id:{data.id};request-id:{x-request-id};ts:{ts}; -
Tolerancia anti-replay: 300 s.
-
El tenant se deduce del identificador de cuenta de MercadoPago (
user_iddel payload) contra la configuración de pasarelas: el webhook llega sin API key. -
Sólo se modela el topic
payment; el resto se persiste eninbound_webhook_eventsy no transiciona nada.
Con medios asíncronos, el webhook no es una optimización, es el único camino: un cupón de pago en
efectivo puede tardar días en pagarse. Tu integración tiene que aceptar que un intent viva en
PROCESSING mucho tiempo y resolverse cuando llegue
payment_intent.succeeded o payment_intent.expired.
Reembolsos#
POST /v1/refunds, total o parcial, igual que en el resto de pasarelas. El hub usa el id del refund
(re_…) como clave de idempotencia hacia MercadoPago, así que un reintento del orquestador no
devuelve dos veces.
status del refund en MercadoPago | Estado del refund |
|---|---|
approved | SUCCEEDED |
pending, in_process | PENDING |
rejected, cancelled | FAILED |
Un refund PENDING se resuelve más tarde por webhook (refund.succeeded o refund.failed). No
asumas que POST /v1/refunds devuelve un desenlace terminal.
Modo test#
Las claves sk_test_ operan contra la misma api.apipay.io. Para MercadoPago necesitas además
usuarios de prueba en su plataforma: vendedor y comprador, cada uno con sus credenciales.
Las tarjetas de prueba y el procedimiento para crear usuarios de prueba no se copian aquí: se desactualizan. Consulta la documentación oficial de pruebas de MercadoPago (se abre en una pestaña nueva).
La única tabla de escenarios que este portal mantiene como propia es la de sandbox, que se controla
con los dos últimos dígitos del monto. Ver Modo test.
Lo que MercadoPago tampoco te va a dar#
payment_methodes siemprenull. El adaptador no captura metadatos de tarjeta por alcance PCI DSS SAQ A, aunque MercadoPago los publique.- Cuotas decididas por ti sin acuerdo previo.
installmentses un parámetro del pago que el Brick negocia con la cuenta del tenant; no es algo que se fuerce desde tu servidor. - Campos de tarjeta propios. Ni número, ni CVV, ni
autocomplete="cc-number". La captura ocurre dentro del Brick o en la página de MercadoPago.
Checklist de integración#
-
return_urlpresente si usas el modo preferencia (alimenta las tresback_urls). - La página de retorno lee el estado con un
GET, no confía en los query params. -
PROCESSINGprolongado aceptado como normal: hay medios asíncronos. -
refundedycharged_backconciliados conGET /v1/transactions, no con el estado del intent. - La orden se libera con
payment_intent.succeeded. - Ningún campo de tarjeta en tu página, ni en un ejemplo.