Mercado Pago
Pix, boleto and card across seven Latin American countries — multi-currency, decimal amounts, and notifications that carry an id and nothing else.
The largest gateway in Latin America. One API, seven country sites (BR, AR, MX, CL, CO,
PE, UY), so unlike the BRL-only Brazilian drivers this one is multi-currency and takes a
required currency.
- Methods: pix, boleto, credit_card, debit_card.
undefinedis deliberately not supported: Mercado Pago requires apayment_method_idon every payment, so "let the payer choose" is Checkout Pro (createCheckout), not a charge. Reach it by naming the provider —payments.driver('mercadopago').createCheckout(…). - Setup:
payments.mercadopago({ accessToken, currency, webhookSecret })—MERCADOPAGO_ACCESS_TOKEN,MERCADOPAGO_WEBHOOK_SECRET.currencyis required (lowercase ISO 4217) and the driver refuses to boot without one; a gateway that bills in seven currencies has no safe default. There is no sandbox host — you switch environments by switching credentials. - Money: Mercado Pago takes
transaction_amountas a decimal, so the driver converts at the boundary. The currency travels with the conversion:clphas no cents, and dividing a Chilean amount by 100 bills 1% of it — which Mercado Pago accepts without complaint.copis treated as a two-decimal currency because ISO 4217 says so, even though Colombian prices are quoted in whole pesos; verify a COP charge in sandbox before trusting it. idempotencyKey: sent as theX-Idempotency-Keyheader oncharge()andrefund()— the two endpoints Mercado Pago documents (and requires) it on. It is a header, not a body field: a key written into the payload deduplicates nothing. When you don't pass one the driver generates a UUID to satisfy the API, and your retry is then a second charge; pass your own key if you want the protection. OncreateCustomer,createSubscriptionandupdateSubscriptionanidempotencyKeythrows — see What it refuses.- Webhooks:
x-signature(ts=…,v1=…), an HMAC-SHA256 overid:<data.id>;request-id:<x-request-id>;ts:<ts>;keyed by the secret signature from Your integrations → Webhooks. Absent parts are dropped from the manifest, and the docs ask you to lowercase an alphanumericdata.id, so the driver accepts either spelling. Enforced strictly, andwebhookSecretis required — without it the app refuses to boot rather than accepting anything. For local development without a dashboard webhook, name the provider inallowUnverifiedWebhooks. externalReference: sent as Mercado Pago'sexternal_reference— on the payment, on the Checkout Pro preference (from where the gateway copies it onto the resulting payment) and on the preapproval. Mercado Pago restricts it to 64 characters of letters, numbers, hyphens and underscores; the driver refuses anything else up front rather than letting the gateway return an opaque 400.- Subscriptions: preapprovals. Pass
amount(plusmetadata.backUrl) for a subscription with an inline recurrence, or omitamountand pass an existingpreapproval_plan_idasplanIdand let the plan set the price.updateSubscriptionreally updates the gateway (PUT /preapproval/{id}withauto_recurring), anddescriptionmaps to the payer-visiblereason. - Invoices: none for a customer.
listInvoicesthrows — see below.
const payment = await payments.driver('pix').charge({
amount: 1990,
method: 'pix',
externalReference: 'payment_local_1',
idempotencyKey: crypto.randomUUID(),
customer: { name: 'Jane Doe', email: 'jane@example.com', taxId: '123.456.789-00' },
})
payment.pixCode // the BR Code the payer copies
payment.pixQrCodeImage // base64 PNG
payment.hostedUrl // Mercado Pago's own payment pageWebhook verification is required at boot
This driver reports webhookVerification: 'unconfigured' — and the app refuses to boot — when
webhookSecret is not configured (env fallback MERCADOPAGO_WEBHOOK_SECRET).
Set it before you deploy, or — only when verification genuinely happens upstream — name this
provider in allowUnverifiedWebhooks. An empty credential slot is not "skip verification": with nothing to check against,
POST /payments/webhook/mercadopago would accept any body anyone posted to it — including one
that marks a payment paid. The app refuses to start instead. See Configuration.
The notification carries an id and nothing else
This is the single most important thing to know about Mercado Pago webhooks. A notification looks like this, in full:
{ "id": 12345, "type": "payment", "action": "payment.updated", "data": { "id": "999999999" } }No amount. No status. No external_reference. Mercado Pago's own docs tell you to fetch
the resource afterwards, and there is no webhook variant that carries the payment.
So parseWebhook verifies the x-signature HMAC and then fetches the resource:
GET /v1/payments/{id} for the payment topic, GET /preapproval/{id} for
subscription_preapproval, and for topic_chargebacks_wh the payment named by
data.payment_id plus the case itself (see Chargebacks).
What you get back is a fully populated event — status, amount, currency and your
externalReference:
// event.type === 'payment.succeeded'
// event.data.externalReference === 'payment_local_1'One extra API call per notification is Mercado Pago's design, not an inefficiency of the
driver; the mounted /payments/webhook/mercadopago route pays it for you. A failed fetch
throws, so the route answers 400 and Mercado Pago retries — reporting a status the
gateway did not confirm would be worse than a retry.
The one exception is subscription_authorized_payment, a subscription's recurring charge.
It is read from /authorized_payments/{id}, whose response shape this driver has not
verified against the reference, so it is left as payment.updated carrying the id rather
than mapped from guessed field names. Fetch it yourself if you need the charge's status.
The signature is computed over the query string
Mercado Pago builds the manifest from the data.id query parameter, and the driver
contract passes only the body and the headers. The driver reads the same id from the body,
where Mercado Pago also puts it. Should the two ever disagree for a notification type, the
signature check will reject a genuine webhook.
Chargebacks and mediations
Mercado Pago says everything about a dispute through the payment, not through the
notification: the topic tells you a case changed, and status + status_detail say what
that means.
| Payment status | status_detail | Normalized | The money |
|---|---|---|---|
in_mediation | — | payment.disputed | a claim opened inside Mercado Pago |
charged_back | in_process | payment.disputed | chargeback received, awaiting the decision |
charged_back | settled | payment.dispute_closed (lost) | gone — "decision against the seller" |
charged_back | reimbursed | payment.dispute_closed (won) | returned to the seller's account |
settled and reimbursed are Mercado Pago's own words for the outcome — "decision against
the seller, money withdrawn from the seller's account" and "decision in favor of the
seller, money refunded to the seller's account". Reading them is what turns a dispute that
ends into one that ends in the ledger: a won dispute left sitting at disputed writes
off money that came back, because revenue() sums rows that say paid. A charged_back
payment whose status_detail this driver does not recognize stays payment.disputed rather
than closing on a guess.
charged_back maps to payment.disputed, never payment.refunded: a refund says the seller
gave the money back voluntarily, and once the row reads refunded the two are
indistinguishable.
Disputes also arrive on their own webhook topic, topic_chargebacks_wh, which the
driver handles:
{
"type": "topic_chargebacks_wh",
"actions": ["changed_case_status"],
"data": { "id": "233000061680860000", "payment_id": "999999999", "checkout": "cho-pro" }
}data.id there is the chargeback case id (GET /v1/chargebacks/{id}), not a payment
id — so the driver fetches the payment named by data.payment_id and files the dispute
against that. A notification with no payment_id stays payment.updated rather than
writing a row under a case id nothing reconciles. Mercado Pago sends one action string for
opening and for every later status change, so what the event means is decided by the
payment's status, not by the notification.
The deadline is on the case, so the chargeback topic fetches it
On topic_chargebacks_wh — and only there, because it is the only notification that names a
case — the driver makes a second call, GET /v1/chargebacks/{data.id}, for the one field
that makes a Mercado Pago dispute actionable:
| Chargeback field | Normalized |
|---|---|
date_documentation_deadline | actionableUntil — the date evidence has to be uploaded by |
reason | reason |
| the case id | disputeId |
date_documentation_deadline is null whenever documentation_required is false: there is
nothing to defend, so there is no clock, and the event carries no actionableUntil.
That second call fails soft, unlike the payment fetch. By the time it runs the money question is already answered by the payment's status; throwing would turn a chargeback the driver read correctly into a 400 and a redelivery. A missing deadline costs an operator context, a dropped chargeback costs the row.
No pre-dispute warning exists here
Mercado Pago publishes no TC40/SAFE-style alert and no inquiry event: topic_chargebacks_wh
fires on a case that already exists, so the first thing you hear is the chargeback. There
is nothing for this driver to map onto payment.dispute_warning, and it does not invent one.
The nearest thing is topic_claims_integration_wh ("creation of refunds and claims"), whose
data.id is a claim id with no payment beside it — nothing to file a warning against —
so it is left unmapped and reaches your handlers under its own name.
Authorized, not captured
status: "authorized" (status_detail: "pending_capture") maps to authorized: the
card is held and nothing has moved. Collapsing it into pending understates a hold the issuer
already granted, and paid would grant access against money that evaporates if the authorization
is never captured. The webhook for it is payment.updated;
the contract has no authorization event.
Paused subscriptions
A preapproval with status: "paused" maps to paused — billing has stopped, the
subscription is alive and can be reactivated by PUT-ing status: "authorized", and it must
not entitle the payer meanwhile. past_due would say a charge failed; nothing failed,
Mercado Pago was told to stop.
What it refuses
listInvoicesthrows.GET /authorized_payments/searchlists the charges of a subscription and filters bypayer_id, which is a Mercado Pago user id — a different identifier space from the/v1/customersid. Returning an empty array would say "this customer has no invoices", which is not something the API told us.cancelSubscription({ atPeriodEnd: true })throws. A preapproval cancel is immediate and, in Mercado Pago's own words, irreversible. Pause it (status: "paused") and cancel at the end of the period, or keep the grace period on your own record.- A subscription
startDatewithout an end date throws. Mercado Pago documents thatstart_date"only works together with theend_dateparameter" — sent alone it is silently ignored, and a subscription starting on a date nobody chose is worse than an error. Passmetadata.endDatealongside it. - A card charge without the brand throws. Mercado Pago identifies a card payment by its
brand (
visa,master,debvisa), which the frontend tokenizer returns next to the token; pass it asmetadata.paymentMethodId. - A charge with no method throws, pointing you at
createCheckout. - An
idempotencyKeyoncreateCustomer,createSubscriptionorupdateSubscriptionthrows.X-Idempotency-Keyis documented — and mandatory — onPOST /v1/paymentsandPOST /v1/payments/{id}/refunds; the reference for/v1/customersand/preapprovallistsAuthorizationand nothing else. Sending it there anyway would be harmless and unspecified, which is the worst combination: you would believe your retry is safe. Look a preapproval up byexternal_referencebefore you retry instead.
Gaps and things the docs would not settle
- The reference marks
transaction_amount,payment_method_idandtokenas optional while its own error table says each "can't be null". Requiredness here follows the errors and the integration guides, not the schema flags. payment_method_idfor Pix isPixin the API reference's enum andpixin every working guide. The driver sends the lowercase form.- The payer address sits at
additional_info.payer.addressin the reference and directly underpayerin the Pix and boleto guides. The driver follows the guides (metadata.address→payer.address), because those are the examples with a working end-to-end flow. identification.typeis inferred as CPF/CNPJ from the digit count, which only holds for Brazil. For AR/MX/CL/CO/PE/UY pass the document type yourself.- The set of
actionvalues is not documented — onlypayment.createdappears anywhere — so the driver keys off the notification'stype, which is. - The docs never promise that a chargeback also reaches the
paymenttopic; they only document it ontopic_chargebacks_wh. The driver handles both, so subscribe to both. - Whether the funds leave your account when the chargeback opens, or only when it
settles, is not stated. Mercado Pago describes
settledas the withdrawal, while the payment's top-level status is alreadycharged_backfrom the opening and the guides describe the amount as retained from then on. Because the reference will not settle it,charged_back/in_processkeeps thepayment.disputedit has always had rather than being downgraded to a warning on a guess. The same goes forin_mediation, which the reference describes only as "users have initiated a dispute". subscription_authorized_payment(a subscription's recurring charge) is still left aspayment.updated: it is read from/authorized_payments/{id}, whose response shape this driver has not verified against the reference.
InfinitePay
CloudWalk's Brazilian checkout — a redirect-only driver, because the payment link API is the only one InfinitePay documents. charge(), refunds, customers and subscriptions throw.
Stripe
Cards, Pix and boleto on a Brazilian account, hosted invoices, and the Idempotency-Key header — the multi-currency default of the four.