convex-mercadopago
v0.0.4
Published
A Mercado Pago component for Convex.
Downloads
673
Maintainers
Readme
Convex Mercado Pago Component
[!WARNING] This component is under active development and has not reached a stable release. APIs may change without notice between versions, it is not an official
@convex-devcomponent, and it has not yet been battle-tested in production. Pin an exact version, review the CHANGELOG before upgrading, and use at your own risk.
Add Mercado Pago payments and subscriptions to your Convex app: Checkout Pro, Checkout API card payments via Bricks tokens, subscriptions (preapproval), refunds, chargebacks, and a webhook ingestion pipeline with signature verification, deduplication, and pull reconciliation.
Check out the example app for a complete example.
// Create a Checkout Pro preference and redirect the buyer
const { preference } = await mercadopago.createPreference(ctx, {
items: [{ title: "Pro plan", quantity: 1, unit_price: 9990 }],
external_reference: orderId,
back_urls: { success: "https://example.com/success" },
});
// React: the payment status updates live when the webhook lands
const { payment, isLoading } = usePaymentStatus(api.mercadopago.getPayment, {
paymentId,
});Features
- 💳 Checkout Pro & Checkout API - Redirect checkout via preferences, or card payments from Bricks tokens (never raw card data)
- 🔁 Subscriptions - Preapprovals with or without a plan: create, pause, resume, cancel, and card updates
- 💸 Refunds & chargebacks - Full/partial refunds and chargeback sync into payment state
- 🔔 Webhook pipeline -
x-signatureHMAC verification, fingerprinted event ledger with deduplication, fast acknowledgement, fetch-and-apply - 🧭 Forward-only state machine - Out-of-order and duplicate webhooks can never regress a mirrored payment or subscription status
- 🔄 Reconciliation - A cron-friendly action that reprocesses failed events, syncs recent resources, and refreshes stale payments through the same write path as webhooks
- 🪝 Lifecycle hooks -
onPaymentApproved,onSubscriptionCancelled,onChargeback, and friends fire exactly once per transition - 📊 Real-time data - Mirrored tables queried reactively from Convex, with thin React hooks
Prerequisites
Convex App
You'll need a Convex app to use the component. Follow any of the Convex quickstarts to set one up.
Mercado Pago Account
- Create a Mercado Pago developer application
- Copy the access token (TEST or production) from the application's credentials page
- Configure Webhooks for the application and copy the generated secret key for signature validation
Installation
Install the component package:
npm install convex-mercadopagoCreate a convex.config.ts file in your app's convex/ folder and install
the component:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import mercadopago from "convex-mercadopago/convex.config.js";
const app = defineApp();
app.use(mercadopago);
export default app;Set your credentials as environment variables in your Convex deployment:
npx convex env set MERCADOPAGO_ACCESS_TOKEN TEST-...
npx convex env set MERCADOPAGO_WEBHOOK_SECRET ...Usage
Initialize the client
Instantiate the MercadoPago client in your Convex backend. The identify
callback is the single identity seam: it tells the component which app user
owns each payment, and client-supplied user ids are never trusted.
// convex/mercadopago.ts
import { MercadoPago } from "convex-mercadopago";
import { api, components } from "./_generated/api";
import { query } from "./_generated/server";
export const identifyUser = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
return { userId: identity.tokenIdentifier, email: identity.email };
},
});
export const mercadopago = new MercadoPago(components.mercadopago, {
identify: async (ctx) => await ctx.runQuery(api.mercadopago.identifyUser),
events: {
onPaymentApproved: async (ctx, payment) => {
// Grant access, send a receipt, etc.
},
onSubscriptionCancelled: async (ctx, preapproval) => {
// Revoke access.
},
},
});Constructor options:
identify: (Optional) Function returning the current user's{ userId, email }. Required for theapi()factory and user-scoped readsevents: (Optional) Lifecycle hooks (see Webhooks)accessToken: (Optional) Mercado Pago access token. Falls back to theMERCADOPAGO_ACCESS_TOKENenv varwebhookSecret: (Optional) Webhook secret key. Falls back to theMERCADOPAGO_WEBHOOK_SECRETenv varapiBaseUrl: (Optional) Override thehttps://api.mercadopago.combase URLallowUnverified: (Optional) Accept webhooks without a configured secret. Defaults tofalse; do not enable in production
Set up webhooks
Mount the webhook route in convex/http.ts:
// convex/http.ts
import { httpRouter } from "convex/server";
import { mercadopago } from "./mercadopago";
const http = httpRouter();
mercadopago.registerRoutes(http);
export default http;Then, in the Mercado Pago dashboard under Your integrations → Webhooks, point notifications at your Convex site URL:
https://<your-deployment>.convex.site/mercadopago/webhookSubscribe at least to the payment, merchant_order, and subscription topics
you use. The route path can be overridden with
registerRoutes(http, { path: "/custom/path" }).
Checkout Pro
Create a preference and redirect the buyer to init_point
(sandbox_init_point with TEST credentials):
// convex/mercadopago.ts
import { v } from "convex/values";
import { action } from "./_generated/server";
export const createCheckout = action({
args: { title: v.string(), amount: v.number(), orderId: v.string() },
handler: async (ctx, args) =>
await mercadopago.createPreference(ctx, {
items: [{ title: args.title, quantity: 1, unit_price: args.amount }],
external_reference: args.orderId,
back_urls: { success: "https://example.com/success" },
}),
});[!TIP] Always pass
external_reference. It is the canonical join key between your app's orders and Mercado Pago resources, and it makes preference creation retry-idempotent — Mercado Pago does not honorX-Idempotency-Keyon/checkout/preferences, so without it a crash retry can create duplicates.
Checkout API (card payments)
Tokenize the card in the browser with Bricks / the JS SDK — card numbers must never reach your backend — then create the payment with the token:
await mercadopago.createCardPayment(ctx, {
token: cardToken,
transaction_amount: 1000,
installments: 1,
payment_method_id: "visa",
payer: { email },
});Two-step authorization and capture:
await mercadopago.createCardPayment(ctx, { ...args, capture: false });
await mercadopago.capturePayment(ctx, { paymentId, amount: 1000 });
// or release the hold:
await mercadopago.cancelPayment(ctx, { paymentId });Refunds
await mercadopago.refundPayment(ctx, { paymentId }); // full
await mercadopago.refundPayment(ctx, { paymentId, amount: 500 }); // partialDistinct partial refunds get distinct idempotency keys automatically; retrying
the same call reuses the same key.
After a successful refund, the payment mirror is re-synced immediately when
Mercado Pago can return the payment snapshot. Lifecycle hooks such as
onPaymentRefunded still fire from webhook or reconciliation transitions.
Partial refunds keep the payment approved until it is fully refunded, and some
Mercado Pago sandbox accounts reject partial refunds with error 2084.
Subscriptions
Create a preapproval directly or from a plan. Without a card_token_id the
subscription starts pending and the buyer authorizes it at init_point:
// Standalone subscription
await mercadopago.createPreapproval(ctx, {
reason: "Monthly membership",
external_reference: subscriptionId,
payer_email: email,
back_url: "https://example.com/subscriptions/return",
auto_recurring: {
frequency: 1,
frequency_type: "months",
transaction_amount: 1000,
currency_id: "CLP",
},
});
// From a plan
await mercadopago.createPreapproval(ctx, {
preapproval_plan_id: planId,
payer_email: email,
});back_url is required when creating a preapproval without a plan.
Manage the lifecycle:
await mercadopago.pausePreapproval(ctx, { preapprovalId });
await mercadopago.resumePreapproval(ctx, { preapprovalId });
await mercadopago.cancelPreapproval(ctx, { preapprovalId });
await mercadopago.updatePreapprovalCard(ctx, { preapprovalId, cardTokenId });Recurring charge attempts arrive as subscription_authorized_payment webhooks
and are mirrored into the authorizedPayments table.
Query payment and subscription state
Mirrored state lives in component tables and is queried reactively — your UI updates live when a webhook lands:
export const getMyPayments = query({
args: {},
handler: async (ctx) => {
const { userId } = await ctx.runQuery(api.mercadopago.identifyUser);
return await mercadopago.listPaymentsByUser(ctx, { userId });
},
});
const status = await mercadopago.getSubscriptionStatus(ctx, { userId });
// { active: true, source: "preapproval", preapprovalId: "..." }getSubscriptionStatus counts only authorized preapprovals by default. Pass
approvedPaymentWindowMs to also count recent approved subscription payments
within a time window.
Ready-made host functions
api() returns the core methods pre-bound for re-export, with identity
resolved exclusively through your identify callback:
export const {
createPreference,
createCardPayment,
capturePayment,
cancelPayment,
refundPayment,
createPreapproval,
getPayment,
getSubscriptionStatus,
reconcile,
} = mercadopago.api();API Reference
MercadoPago Client
Commands (call the Mercado Pago API, then apply the result to the mirror):
createPreference(ctx, args)- Create a Checkout Pro preferencecreateCardPayment(ctx, args)- Create a payment from a Bricks token; supportscapture: falsecapturePayment(ctx, { paymentId, amount?, idempotencyKey? })- Capture an authorized paymentcancelPayment(ctx, { paymentId, idempotencyKey? })- Cancel a paymentrefundPayment(ctx, { paymentId, amount?, idempotencyKey? })- Full or partial refundcreatePreapproval(ctx, args)/cancelPreapproval/pausePreapproval/resumePreapproval/updatePreapprovalCard- Subscription lifecyclecreatePlan(ctx, args)/updatePlan(ctx, { planId, ...args })- Preapproval plan managementgetOrCreateCustomer(ctx, { email, userId? })- Find-by-email or create a Mercado Pago customer, mirror it, and sync its saved cards
Reads (reactive queries over mirrored tables):
getPayment(ctx, { paymentId })/getPaymentByExternalReference(ctx, { externalReference })/listPaymentsByUser(ctx, { userId })getPreference(ctx, { preferenceId })getPreapproval(ctx, { preapprovalId })/listPreapprovalsByUser(ctx, { userId })getSubscriptionStatus(ctx, { userId, approvedPaymentWindowMs? })getCustomerByUserId(ctx, { userId })
Sync (fetch from Mercado Pago and apply through the canonical write path; used by webhooks and reconciliation, callable manually):
syncPayment(ctx, { paymentId })syncPreapproval(ctx, { preapprovalId })syncMerchantOrder(ctx, { merchantOrderId })syncAuthorizedPayment(ctx, { authorizedPaymentId })
Infrastructure:
registerRoutes(http, { path? })- Mount the webhook route (default/mercadopago/webhook)reconcile(ctx, { lookbackDays? })- Run pull reconciliation (see Reconciliation)api()- Pre-bound host functions (see above)
React
usePaymentStatus(queryRef, args)- WrapsuseQueryover a host-exposed payment query; returns{ payment, isLoading }useSubscriptionStatus(queryRef, args)- Same shape for subscription statusgetCheckoutUrl(preference, { sandbox })- Picksinit_pointorsandbox_init_point
Webhooks
Handled topics: payment, merchant_order, preapproval,
subscription_preapproval, subscription_authorized_payment,
subscription_preapproval_plan, chargebacks, and their topic_*_wh
aliases.
For every delivery the route:
- Reads the raw request body and verifies the
x-signatureHMAC-SHA256 against it (constant-time comparison, timestamp tolerance). Only QR Code notifications (point_integration_wh) are exempt; everything else is rejected with 401 when the signature is missing or invalid. - Records a fingerprinted event in the ledger. Duplicates are acknowledged with 200 immediately and never re-run your hooks.
- Acknowledges within Mercado Pago's 22-second window, fetching the full
resource by id (notifications only carry
data.id) and applying it through the same canonical mutation used by reconciliation. - Diffs the before/after state and fires your lifecycle hooks exactly once per transition.
Available hooks: onPaymentCreated, onPaymentApproved, onPaymentRejected,
onPaymentRefunded, onPaymentChargedBack, onPaymentInMediation,
onSubscriptionAuthorized, onSubscriptionPaused,
onSubscriptionCancelled, onAuthorizedPaymentProcessed, onChargeback, and
the catch-all onEvent.
Chargeback notifications fetch the chargeback resource, sync every payment in
its payments[] array, and fire onChargeback once in addition to the
per-payment hooks.
If a redelivered event matches a ledger row in failed state it is reset and
processed again (with retryCount incremented), so Mercado Pago's automatic
retries recover transient failures. Any other existing state stays a
duplicate.
Reconciliation
Webhooks can be missed, and sandbox credentials never send them. Register a cron that calls the reconciliation action:
// convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval(
"reconcile Mercado Pago",
{ hours: 6 },
internal.mercadopago.reconcile,
{},
);
export default crons;reconcile(ctx, { lookbackDays? }) (default 3 days):
- Reprocesses up to 25
failedledger events, plus events stuck inprocessingfor over 15 minutes after an interrupted run - Searches recent payments, preapprovals, and authorized payments and applies them through the same canonical write path as webhooks
- Re-syncs mirrored payments still in a non-terminal status
(
pending/in_process/authorized) older than one hour
It returns { failedReprocessed, failedStillFailing, stuckReprocessed, paymentsSynced,
preapprovalsSynced, authorizedPaymentsSynced, staleResynced, errors }.
Lifecycle hooks fire on reconciliation-applied transitions exactly as they do for
webhooks. Each search endpoint uses its own filter semantics: payments by date
range, preapprovals by range=last_modified:after:<iso>, and authorized
payments by preapproval id.
Database Schema
The component owns these tables (all provider ids stored as strings and indexed; raw provider payloads kept for audit):
| Table | Purpose | Key fields |
| --- | --- | --- |
| preferences | Checkout Pro preferences | preferenceId, externalReference, userId, initPoint, items, status |
| payments | Payment mirror | paymentId, externalReference, userId, status, statusDetail, transactionAmount, preferenceId, preapprovalId, mpLastModified |
| refunds | Refunds per payment | refundId, paymentId, amount, status |
| merchantOrders | Checkout Pro order aggregation | merchantOrderId, preferenceId, orderStatus, paidAmount, refundedAmount |
| preapprovals | Subscriptions | preapprovalId, preapprovalPlanId, externalReference, userId, status, autoRecurring, nextPaymentDate |
| preapprovalPlans | Subscription plan templates | planId, status, reason, autoRecurring |
| authorizedPayments | Recurring charge attempts | authorizedPaymentId, preapprovalId, paymentId, status, retryAttempt |
| customers | Mercado Pago customers | mpCustomerId, userId, email |
| customerCards | Saved card metadata (never PAN/CVV) | cardId, mpCustomerId, lastFour, expirationMonth, expirationYear |
| webhookEvents | Ingestion ledger | fingerprint, topic, dataId, verified, processingState, retryCount |
Statuses are stored as strings on purpose — Mercado Pago adds new ones — and
every write is gated by a forward-only state machine plus a
date_last_updated guard, so stale or out-of-order data can never overwrite
newer state.
Testing
The ./test export provides a register helper for
convex-test:
import { convexTest } from "convex-test";
import { register } from "convex-mercadopago/test";
import schema from "./convex/schema";
const t = convexTest(schema);
register(t);register(t, name = "mercadopago") registers the component schema and modules
under the same component name used in convex.config.ts.
Sandbox testing
Mercado Pago sandbox behavior is driven by
test cards
and the cardholder name (APRO approves, CONT stays pending, FUND/SECU
reject). Two manual scripts exercise the real sandbox with credentials from
.env.local (never committed, never printed):
node scripts/sandbox-smoke.mjs # preference round-trip + payment search
node scripts/sandbox-live-tests.mjs # 10-test suite incl. idempotency probesExample App
See example/ for a complete backend-only host app: client
initialization with identify, checkout and refund commands, webhook
registration, lifecycle hook logging, and a reconciliation cron.
Troubleshooting
401 on webhook delivery: check for a wrong or rotated
MERCADOPAGO_WEBHOOK_SECRET, and verify the signature against the raw request
body before any re-serialization. The only unsigned-topic exception is QR Code
point_integration_wh; Checkout Pro merchant-order webhooks must be signed
when a secret is configured.
Mercado Pago error code 4292: a non-GET request is missing a valid
X-Idempotency-Key. The client generates deterministic keys for built-in
write methods unless you override them.
No sandbox webhooks: Mercado Pago does not deliver webhooks for payments
made with test credentials. Use reconcile() or manual polling during
development.
Error 2034 "Invalid users involved" or 4390 "Payer email forbidden" in
sandbox: when paying with TEST credentials of a production account, the
payer.email must be a plain email address that is not registered on
Mercado Pago at all — not a @testuser.com address and not your own account
email. @testuser.com payer emails are rejected (4390 for payments, 400 for
preapproval), and unpaired fabricated ones cause 2034 or raw 500s. The same
rule applies to payer_email on preapproval creation.
Refund returns 500 internal_error right after payment: sandbox refunds
fail with a raw 500 until the payment settles; live testing observed this can
take up to ~2 minutes after creation. Retry after a short delay — the same
request succeeds once settled. The adapter's bounded retry does not cover this
window; treat an immediate post-payment refund 500 as retryable later, not as a
permanent failure.
Duplicate preferences after retries: Mercado Pago does not enforce
X-Idempotency-Key on /checkout/preferences (verified live — replaying the
same key creates a new preference), so the client never auto-retries that
endpoint. Pass external_reference and deduplicate on it.
License
MIT — a deliberate divergence from the Apache-2.0 used by official
@convex-dev components.
