Mercado Pago Subscriptions Done Right: Preapproval, Webhooks & Failed Payments (2026) — Cesar Ayala
← All posts

Mercado Pago Subscriptions Done Right: Preapproval, Webhooks & Failed Payments (2026)

Create a subscription with POST /preapproval using a card_token_id and status "authorized" (with or without a reusable preapproval_plan). Then treat webhooks as truth: subscription_preapproval for lifecycle, subscription_authorized_payment for each charge. Fetch every resource by id, grant access only on a confirmed approved charge, and sync cancellations both ways.

Plan or no plan? The two ways to build MP subscriptions

Before you write a single line, decide which of the two subscription models you’re building. Mercado Pago gives you a fork, and picking the wrong side means rework later.

Model 1 — with an associated plan. You create a reusable preapproval_plan (POST /preapproval_plan) once, then subscribe individual payers to it. The plan pre-bakes the recurrence config — amount, frequency, currency — so every subscriber inherits the same terms. This is the right call when you sell standardized tiers (Basic / Pro / Team) that many customers share. Change the plan, and you have one object to reason about.

Model 2 — without a plan. You create the subscription directly with POST /preapproval and pass the auto_recurring config inline. There’s no shared object — each subscription carries its own terms. This wins for custom or one-off subscriptions: per-customer pricing, negotiated amounts, weird cadences, anything you don’t want to standardize.

Here’s the part that trips people up: both paths still subscribe the payer through /preapproval. The plan doesn’t replace /preapproval — it just supplies the recurrence config so you don’t repeat it. With a plan you reference the plan id; without one you spell out auto_recurring yourself. Same endpoint to actually create the payer’s subscription, same card_token_id, same status of "authorized" to charge.

If you’ve wired Stripe, this is the same mental model I described in how to integrate Stripe into your SaaS: a reusable Price object versus an ad-hoc subscription item. Mercado Pago’s preapproval_plan is the Price; plan-less /preapproval is the ad-hoc path. New to MP entirely? Start with Mercado Pago foundations first — this post assumes you already have credentials and the SDK installed.

Decision rule: fixed pricing tiers you reuse across customers, go plan-based. Per-customer custom amounts or cadence, go plan-less.

Two ways to build MP subscriptions

WITH associated plan

  • POST /preapproval_plan once (reusable)
  • Subscribe payers, referencing the plan
  • Recurrence config baked into the plan
  • Best for standardized tiers (Basic/Pro/Team)
  • Change once, all subscribers inherit

WITHOUT plan

  • POST /preapproval directly
  • auto_recurring config passed inline
  • Each subscription carries its own terms
  • Best for custom / one-off subscriptions
  • Per-customer amounts and cadence
Both converge on POST /preapproval to subscribe a payer. The plan just pre-bakes the recurrence config.

Creating the subscription: tokenize the card, then POST /preapproval as “authorized”

The flow has two steps, and people skip the first one. Tokenize the card first. Raw card data (PAN, CVV, expiry) goes from the browser straight to Mercado Pago’s tokenizer — via the JS SDK or Bricks — and you get back a single-use card_token_id. That token is all that touches your server. Your backend never sees the card number, which keeps you out of the worst of PCI scope.

Then you POST /preapproval with that token. The body fields, exactly:

  • reason — the human-readable subscription name the payer sees
  • external_referenceyour join key back to your own user/account row. Set this on day one.
  • payer_email — the subscriber’s email
  • card_token_id — the token you just minted
  • back_url — where MP redirects the payer afterward
  • status — set to "authorized" to charge and activate immediately
  • auto_recurring — the recurrence object, carrying frequency, frequency_type, transaction_amount, currency_id, start_date and end_date

The make-or-break detail: status must be "authorized" to actually charge. Leave it "pending" and you get a subscription object that exists but never bills anyone. I have watched a teammate stare at “why isn’t this charging” for an afternoon — the answer was a missing authorized.

// Node — create a plan-less subscription, charge immediately
const res = await fetch("https://api.mercadopago.com/preapproval", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.MP_ACCESS_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    reason: "Pro plan — monthly",
    external_reference: "user_4821",        // your join key
    payer_email: "payer@example.com",
    card_token_id: cardTokenId,             // tokenized client-side
    back_url: "https://app.example.com/billing/return",
    status: "authorized",                   // <- charges now; "pending" does NOT
    auto_recurring: {
      frequency: 1,
      frequency_type: "months",
      transaction_amount: 299,
      currency_id: "MXN",
      start_date: "2026-07-01T00:00:00.000-06:00",
      end_date: "2027-07-01T00:00:00.000-06:00",
    },
  }),
});
const subscription = await res.json();
// persist subscription.id  +  external_reference  in your DB

Store subscription.id and external_reference together immediately. The MP id is how you fetch the resource later; external_reference is how a webhook finds the right user in your database. Lose either and you’re reconciling by email — don’t.

Subscription runtime, end to end

Tokenize cardClient-side -> card_token_id, never hits your server
POST /preapproval status authorizedCharges now; store id + external_reference
subscription_authorized_payment firesEach recurring cycle, MP notifies your URL
Fetch by idGET the resource from the API, read confirmed status
Grant on approved / revoke on repeated failureAct only on the confirmed status
Tokenize, authorize, then let webhooks drive grant/revoke each cycle.

What state is my subscription in? pending, authorized, paused, cancelled

A subscription moves through a small set of states, and modeling them correctly in your own DB is what keeps support sane.

  • pending — created but not yet authorized; not charging
  • authorized — active and billable; this is the “good” state
  • paused — temporarily not charging, but not terminal; can resume
  • cancelled — terminal, stop everything

Move a subscription between states through the subscription-management endpoints (update / pause / cancel) — never mutate status by writing it yourself and hoping MP agrees. To pause billing, call pause; to stop billing, call cancel. Mercado Pago is the system of record.

Mirror these states in your own database so your app can render “active” / “paused” / “cancelled” without a round-trip — but treat your copy as a cache, not the truth. The truth comes from webhooks, which is the next section. When your DB and MP disagree, MP wins, and you reconcile.

One caution: status names, fees, and limits are time-sensitive. Confirm the current set in the dashboard and the MP Subscriptions docs as of 2026 before you hardcode a state machine around them.

Webhooks are the source of truth: subscription_preapproval vs subscription_authorized_payment

This is the discipline that separates a billing integration that works from one that quietly leaks revenue. The redirect lies. The notification body is a hint. The fetched-by-id resource is truth.

Configure your notification_url and subscribe to two topics:

  • subscription_preapproval — the subscription/preapproval lifecycle: authorized, paused, cancelled. This is how you hear that a subscription changed state.
  • subscription_authorized_paymenteach recurring charge attempt. Every billing cycle, approved or failed, fires one of these.

Also activate the payment topic (singular — that’s the exact identifier) so you catch the underlying payment notifications tied to those charges. (MP subscriptions webhooks docs.)

One shape gotcha before the code: MP delivers notifications in two forms depending on how your account/integration is configured — the modern { type, data: { id } } body and the legacy { topic, resource } body. Handle whichever your account actually receives, and confirm it in the sandbox. The snippet below assumes the modern shape.

The non-negotiable rule: on every notification, take the id, fetch the resource from the API, and act on the confirmed status. Do not branch on the notification body — it tells you something happened, not what is true now. And never, ever grant access off the back_url redirect; a redirect is the browser saying “I came back,” which a user can fake or which can fire before the charge settles.

// Node/Express — webhook for subscription_authorized_payment
import crypto from "node:crypto";

app.post("/webhooks/mp", express.json(), async (req, res) => {
  // 1) Verify the signed event FIRST (illustrative — confirm the exact
  //    x-signature parsing/manifest format in MP's webhook docs).
  const signature = req.header("x-signature") || "";
  const requestId = req.header("x-request-id") || "";
  const ts = (signature.match(/ts=([^,]+)/) || [])[1];
  const v1 = (signature.match(/v1=([^,]+)/) || [])[1];
  const dataId = req.query["data.id"] || req.body?.data?.id;
  const manifest = `id:${dataId};request-id:${requestId};ts:${ts};`;
  const expected = crypto
    .createHmac("sha256", process.env.MP_WEBHOOK_SECRET)
    .update(manifest)
    .digest("hex");
  if (v1 !== expected) return res.sendStatus(401);

  res.sendStatus(200); // ack fast; do work async

  const { type, data } = req.body;        // notification is a hint, not truth
  if (type !== "subscription_authorized_payment") return;

  // 2) Fetch the authorized-payment (invoice) resource by id — source of truth
  const r = await fetch(
    `https://api.mercadopago.com/authorized_payments/${data.id}`,
    { headers: { Authorization: `Bearer ${process.env.MP_ACCESS_TOKEN}` } }
  );
  const charge = await r.json();          // confirmed state lives here

  // charge.status reflects this cycle's outcome; on some accounts the
  // confirmed payment status is nested under charge.payment — confirm in sandbox.
  if (charge.status === "approved") {
    await grantAccess(charge.external_reference, charge);   // extend the period
  } else {
    await recordFailedCharge(charge.external_reference, charge); // -> dunning
  }
});

Note the shape: verify the signature, ack with 200 immediately, then do the fetch-and-branch out of band. MP retries notifications it doesn’t see acked, and you don’t want a slow database write turning into duplicate deliveries. The GET /authorized_payments/{id} path is a real, documented endpoint (authorized payments / invoices reference); just confirm the leaf field names in your sandbox — external_reference may need to be resolved via the invoice’s preapproval_id (the MP subscription id you persisted at creation) rather than read off the invoice directly.

The two topics + the discipline the docs skip

subscription_preapprovalLifecycle: authorized / paused / cancelled
subscription_authorized_paymentEach recurring charge attempt (approved/failed)
payment (also activate)Underlying payment notifications for those charges
Always fetch by idAct on the confirmed status, never the body
Never trust a redirectback_url is a browser hint, not a grant signal
Sync cancellations both waysMP-side and app-side cancels must both propagate
Subscribe to both subscription topics plus payment. Then never trust anything but a fetched-by-id resource.

The part nobody writes: failed payments, dunning, and retries

Here’s the section the official docs wave at and move past — and it’s the entire reason this post exists. A card declines. What happens next decides whether you keep the customer or eat a chargeback.

Mercado Pago retries failed recurring charges on its own schedule. You don’t drive the retries — MP does — and you learn each outcome through subscription_authorized_payment events. So your job isn’t to retry; it’s to react correctly to each attempt.

The logic:

  • On a confirmed approved charge, grant or extend access for the new period. Simple.
  • On a failure, do not cut access on the first decline. Build a grace window that roughly matches MP’s retry cadence, and keep the user live while MP retries. Most failed cards are transient — expired, insufficient funds that clear, a bank’s fraud flag that lifts.
  • On repeated failure (retries exhausted), then revoke or limit access and prompt the payer to update their card.

The trap is MP’s exact retry cadence is not loudly documented. Don’t hardcode “retry on day 3 and 7” from a blog post (including this one). Confirm the current cadence in your dashboard/docs as of 2026, and build your grace window from what you actually observe in the sandbox and early production. If you cut access a day before MP’s last retry would have succeeded, you churned a paying customer for nothing.

Log every charge attempt against external_reference. When a customer emails “you charged me but I have no access” — or “you cut me off but my card is fine” — support needs the full dunning timeline in one place. This is the same discipline I laid out for Stripe in recover failed payments with dunning; the principles port directly to MP, only the retry engine is owned by Mercado Pago instead of you.

Keep cancellations in sync — both directions

Two silent leaks live here, and both cost real money.

Leak 1 — payer cancels inside Mercado Pago. A user can cancel a subscription from within MP, outside your app entirely. You hear about it through subscription_preapproval with status cancelled. If you don’t handle that event, your app shows the user as active forever while MP has stopped charging — you’re giving away the product. Revoke access the moment you confirm the cancelled lifecycle event. Don’t wait for “the next failed charge” to notice; there won’t be one.

Leak 2 — user cancels in your app. When someone hits “cancel” in your UI, you must call MP’s cancel management endpoint so Mercado Pago actually stops billing. Skip it and you keep charging a churned user. That’s not a minor bug — a charge after a user believes they cancelled is a chargeback and a trust hit you don’t recover from.

Treat cancelled as terminal and idempotent on both sides: re-processing a cancellation should be a no-op, never a double-revoke or an error. This two-way sync is maybe twenty lines of code and the cheapest insurance you will ever write. If you’re also running a marketplace where money splits to sellers, the same fetch-and-confirm discipline shows up in Mercado Pago split payments for a marketplace.

Wiring it end to end: the checklist

Sequence everything above into an order you can actually build against:

End-to-end MP subscriptions checklist

  1. Choose the modelPlan-based for tiers, plan-less for custom
  2. Tokenize the cardClient-side -> card_token_id, never on your server
  3. POST /preapproval, status authorizedCharges + activates immediately
  4. Store external_reference + MP idYour join key and the fetch handle, together
  5. Configure notification_url + topicssubscription_preapproval, subscription_authorized_payment, payment
  6. Verify signature, then fetch by idCheck x-signature, ack 200, GET resource, branch on confirmed status
  7. Grant on confirmed approved chargeExtend the access period only on truth
  8. Implement dunning + grace windowReact to retries; don't cut on first failure
  9. Wire two-way cancel syncMP-cancel -> revoke; app-cancel -> call cancel endpoint
  10. Sandbox test + daily reconcileTest card before launch; daily job reconciles against MP
Build in this order; the last step — sandbox + daily reconcile — is your backstop.

That last line matters. Webhooks get missed — networks drop, your server restarts mid-delivery, a deploy eats a request. Run a daily reconciliation job that pulls active subscriptions from MP and compares them to your DB. It catches the one webhook in ten thousand you didn’t process, before a customer does.

FAQ

Plan vs no plan — which should I use? Standard reusable tiers across many customers, use a preapproval_plan. Custom per-customer amounts or cadence, create the subscription directly with /preapproval. Both subscribe the payer through /preapproval.

Why didn’t my subscription charge? Almost always one of two things: status isn’t "authorized" (a pending subscription exists but never bills), or the card_token_id wasn’t attached. Check both.

Can I trust the webhook body? No. The notification tells you something happened; it does not tell you what is true. Verify the x-signature header, then fetch the resource by id and act on the confirmed status. Never grant access off the back_url redirect either.

What happens on a failed card? Mercado Pago retries on its own schedule. You react to each attempt via subscription_authorized_payment events: extend access on a confirmed approved charge, hold a grace window during retries, and revoke only after repeated failure. Confirm MP’s current retry cadence in the dashboard — it isn’t loudly documented.

User cancelled in MP but my app still shows active? You’re not handling the subscription_preapproval event with status cancelled. Subscribe to that topic, fetch by id to confirm, and revoke access. Don’t wait for a failed charge that will never come.

Ship it: build order beats heroics

The happy path — tokenize, authorize, charge — is the easy 20%. The money is saved or lost in the other 80%: failed-payment dunning that keeps good customers through a transient decline, and two-way cancel sync that stops you billing someone who already walked. Those are the two things the docs skip, and they’re exactly where production revenue lives.

So sequence it deliberately. Build the verify-signature-then-fetch-by-id handler before anything that grants access, model your subscription states off the webhooks rather than your own writes, wire both cancel directions, and run the daily reconciliation job as the backstop for the webhooks you’ll inevitably miss. Get that skeleton standing before you tune dunning windows. For the current field names, statuses, topic strings, and retry cadence, go to the MP Subscriptions docs and the preapproval API reference — and confirm them in the dashboard as of 2026 before you ship.