MercadoPago
The Colombian payment rail. Live in exactly one place — course enrollment. Stripe does not serve Colombia well; this is the reason MercadoPago exists in the portfolio at all.
Everything below is extracted from a mercadopago.ts / mpWebhookDecision.ts pair,
both of which have unit tests.
Setup
npm i mercadopago # official SDK v2 — github.com/mercadopago/sdk-nodejs
MERCADOPAGO_ACCESS_TOKEN= # server-only, never NEXT_PUBLIC_
MERCADOPAGO_WEBHOOK_SECRET= # from the MP dashboard, webhook config
import MercadoPagoConfig, { Preference, Payment } from "mercadopago";
function getClient(): MercadoPagoConfig {
const token = process.env.MERCADOPAGO_ACCESS_TOKEN;
if (!token) throw new Error("MERCADOPAGO_ACCESS_TOKEN is not set");
return new MercadoPagoConfig({ accessToken: token });
}
Checkout — create a Preference, redirect to init_point
MP is redirect-based, not an embedded element like Stripe's Payment Element. You create a Preference server-side and send the user to the URL it returns.
const preference = new Preference(getClient());
const response = await preference.create({
body: {
items: [{
id: `course-${courseId}`,
title: courseName,
quantity: 1,
unit_price: unitPrice, // final price AFTER discount
currency_id: "COP",
}],
payer: { email: buyerEmail },
back_urls: {
success: `${baseUrl}/academia?payment=success`,
failure: `${baseUrl}/academia?payment=failure`,
pending: `${baseUrl}/academia?payment=pending`,
},
auto_return: "approved",
notification_url: `${baseUrl}/api/webhooks/mercadopago`,
external_reference: String(enrollmentId), // ← your row id, comes back on the webhook
metadata: { enrollment_id: enrollmentId, course_id: courseId,
original_price: originalPrice, discount_pct: discountPct },
},
});
// response.init_point → production checkout URL
// response.sandbox_init_point → sandbox URL
Rules
currency_id: "COP". Colombian pesos have no minor unit in practice —unit_priceis whole pesos. Do not multiply by 100 the way you would for Stripe'samountin cents. This is the single easiest way to overcharge a customer by 100×.external_referenceis your join key. Put your own row id there; the webhook gives it back. Don't try to match on email or amount.metadatais for humans and audit. Never trust it for control flow — it round-trips through MP unvalidated.- There is a
pendingoutcome that is neither success nor failure. Colombian users pay by bank transfer (PSE) and cash (Efecty), which settle later. Your UI must handle three outcomes, not two.
Webhook verification — fail closed
MP signs deliveries with an x-signature header. The manifest is exact:
manifest = `id:<data.id>;request-id:<x-request-id>;ts:<ts>;`
v1 = HMAC_SHA256(manifest, MERCADOPAGO_WEBHOOK_SECRET)
x-signature arrives as ts=<unix>,v1=<hex>.
export const WEBHOOK_TS_MAX_AGE_SECONDS = 10 * 60; // replay window
// 1. no secret configured → REJECT. Including in development. No silent bypass.
// 2. id, x-request-id, ts, v1 must all be present
// 3. |now - ts| must be within the replay window
// 4. compare with crypto.timingSafeEqual, never ===
Our implementation returns a discriminated result rather than a boolean, so the route can
log why a delivery was refused: missing_secret · missing_header ·
malformed_header · missing_request_id · stale_timestamp · bad_signature.
The trailing semicolon in the manifest is required.
id:X;request-id:Y;ts:Z;— drop it and every signature mismatches with no useful error.
The webhook guard — this is the part people get wrong
MercadoPago retries webhooks, and retries arrive out of order. A late in_process
delivery landing after approved will, in a naive handler, flip a paid enrollment back to
pending and overwrite paid_at. That was a real audit finding here (C-3).
Two guards, both pure functions in mpWebhookDecision.ts, unit-tested without Next or Supabase:
1 · Status map — MP status → our status:
| MP | ours |
|---|---|
approved |
approved |
rejected |
rejected |
pending, in_process |
pending |
cancelled, refunded, charged_back |
cancelled |
2 · Allowed transitions — terminal states never regress:
pending → approved | rejected | cancelled | pending
approved → cancelled | approved ← only refund/chargeback leaves approved
rejected → rejected ← terminal
cancelled → cancelled ← terminal
Plus idempotency: once an enrollment is bound to an mp_payment_id, deliveries for a
different payment id are ignored, and a repeat of the same id+status is a no-op.
The decision function returns one of ignore_other_payment · dedupe ·
ignore_regression · update (with stampPaidAt only on the first approval).
Copy this shape for any redirect-based processor. The pattern — pure decision function, transition map, idempotency key — is the reusable part, not the MercadoPago specifics.
Reading a payment
const payment = new Payment(getClient());
await payment.get({ id: paymentId });
The webhook carries only data.id. Fetch the payment to learn the real status; never trust
a status value that arrived in the request body.
Gotchas
- No official MCP server and no official agent skill. This document is the reference.
auto_return: "approved"only redirects on success. Failure and pending land onback_urlswithout it.sandbox_init_pointmay be absent — fall back toinit_point.- Test cards and sandbox users are per-country; a Colombian sandbox user cannot pay an Argentine test preference.
See also
ekx-stripe — the other fiat rail, everywhere except Colombia