# Ekx Mercadopago

> MercadoPago checkout for the Colombian market, as used for course enrollment — the official Node SDK v2, Preference creation in COP, x-signature webhook verification with replay protection, and the idempotent status-transition guard that keeps a retried webhook from downgrading a paid enrollment. Use when taking payment in COP, building a checkout redirect, handling an MP webhook, or debugging a payment that flipped back to pending.

- Skill: `ekinoxis-evm/ekx-mercadopago` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ekinoxis-evm/ekx-mercadopago`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ekinoxis-evm/ekx-mercadopago/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: Ekinoxis-evm (https://skillmd.com/u/ekinoxis-evm)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ekinoxis-evm/ekx-mercadopago

---


# 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

```bash
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
```

```ts
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.

```ts
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_price`
  is whole pesos. Do **not** multiply by 100 the way you would for Stripe's `amount` in
  cents. This is the single easiest way to overcharge a customer by 100×.
- **`external_reference` is your join key.** Put your own row id there; the webhook gives it
  back. Don't try to match on email or amount.
- `metadata` is for humans and audit. Never trust it for control flow — it round-trips
  through MP unvalidated.
- There is a **`pending`** outcome 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>`.

```ts
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

```ts
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 on
  `back_urls` without it.
- `sandbox_init_point` may be absent — fall back to `init_point`.
- Test cards and sandbox users are per-country; a Colombian sandbox user cannot pay an
  Argentine test preference.

## See also

[`ekx-stripe`](../ekx-stripe/SKILL.md) — the other fiat rail, everywhere except Colombia

