← All notes

Two ways to take a card payment in Mexico — hand off the charge, or own it

June 2, 20265 min read

Taking a card payment online comes down to a question that seems unimportant until you answer it in code: who touches the card? I wired two Mexican gateways into this site —Clip and Mercado Pago— and each answers differently. One keeps the whole charge and hands you back just an identifier. The other lends you its form but makes you the owner of the charge. Both are correct; they pick different things.

The thread that connects them is the same, and it's worth saying up front: the browser never holds a credential that can move money. It holds a token —an identifier for one payment, or a token for one card— and the key that actually charges lives only on the server. Everything else is how much of the operation you delegate.

Clip: hand off everything, keep an id

The Clip flow I used is its Checkout (payment links). The server —and only the server— makes a POST to api.payclip.com/v2/checkout with an Authorization: Basic <base64(api_key:secret)> header. Clip answers with a payment_request_id and a URL. That's the only thing that crosses to the browser: the payment_request_id. The API_KEY and SECRET never leave the server.

// server: the key never touches the client
const token = Buffer.from(`${API_KEY}:${SECRET}`).toString("base64");
const res = await fetch("https://api.payclip.com/v2/checkout", {
  method: "POST",
  headers: { Authorization: `Basic ${token}`, "Content-Type": "application/json" },
  body: JSON.stringify({ amount, currency: "MXN", purchase_description }),
});
// → { payment_request_id, payment_request_url }

To mount the checkout inside the page instead of redirecting, Clip ships a script — sdk.clip.mx/js/v1/checkout.js — that reads the payment_request_id off its own tag and binds to a <button class="clipCheckoutButton">. And here is the one real wall in the whole integration:

checkout.js registers its listener on DOMContentLoaded. If you inject the script and button after mount —say, from a useEffect— that event has already fired, and the script never finds the button. You're left with a dead button, no error, no clue. The cure is to render the <script> and <button> in the initial HTML, server-side, so the listener registers before DOMContentLoaded happens. It isn't a Clip bug; it's a collision between "their script assumes static HTML" and "React paints late." Once you see it, it's obvious. Before you see it, it eats an afternoon.

Clip's trade-off is crisp: zero PCI. The card form is Clip's, on Clip's surface. Your code never sees a card number. In exchange, the in-page experience is the one Clip gives you, and your only point of control is when and for how much you create the link.

Mercado Pago: you tokenize, you charge

Mercado Pago, through its Checkout Bricks, splits the operation in half and hands you the top half. The client SDK (sdk.mercadopago.com/js/v2) mounts a cardPayment Brick that renders the form in its own iframes —the sensitive fields stay on MP's side, same as Clip— and tokenizes the card in the client using your public key. The public key is public on purpose: all it can do is turn a card into a single-use token.

const mp = new MercadoPago(PUBLIC_KEY, { locale: "es-MX" });
mp.bricks().create("cardPayment", "container", {
  initialization: { amount },
  customization: { paymentMethods: { maxInstallments: 1 } }, // no installments
  callbacks: { onSubmit: (formData) => fetch("/api/mp/pay", { /* … */ }) },
});

What changes is what happens in onSubmit. The Brick doesn't charge: it hands you { token, payment_method_id, issuer_id, installments, payer } and you send it to your own endpoint, which makes the POST to api.mercadopago.com/v1/payments with Authorization: Bearer <ACCESS_TOKEN> —server-only, never exposed. That shift from "Clip charges for you" to "you make the charge" brings responsibilities that simply didn't exist with Clip:

  • X-Idempotency-Key per request, or a double click charges twice.
  • You parse the status (approved / in_process / rejected) and status_detail. There's no free "thank you" page; the result is yours to interpret.
  • maxInstallments: 1 if you don't want to offer months-without-interest — the default does.

More control over the form and the payment object, in exchange for owning the charge and everything that can go wrong inside it.

The boundary they share

For all how different they look, both draw the same line in the same place. The browser only ever gets to hold a token: a payment_request_id with Clip, a card token with Mercado Pago. Neither can move money on its own. The credential that does charge —Clip's SECRET, Mercado Pago's ACCESS_TOKEN— lives only on the server, behind the call the client never makes.

That's why neither integration puts a key in a NEXT_PUBLIC_ variable. Mercado Pago's public key is public —it tokenizes, it doesn't charge— but the access token never is. That asymmetry, "what tokenizes can be public, what charges cannot," is the heart of both.

When to reach for which

After wiring both, my rule is short:

  • Clip (embedded link) when I want a zero-PCI footprint and a fast drop-in — or to charge remotely, where you just send a URL. You hand off control of the experience; you gain having nothing to guard.
  • Mercado Pago (Bricks) when I want the form inside my own flow and command over the payment object — its state, its retries, its reconciliation. You gain control; you sign for the responsibility.

Neither is "better." They're different answers to how much of the charge do you want to be yours?

The widgets above, with the wires cut

Both checkouts in this note are genuinely interactive, with the one caveat that matters: the wire that moves money is cut. The Mercado Pago one is the real Brick —it really mounts, it really tokenizes your card with the public key; try a test card— it's just that its onSubmit never calls the endpoint that charges: it drops the token and returns a simulated "approved." The Clip one is a faithful reconstruction: its real checkout lives on Clip's domain and needs a live payment link, so here it's rebuilt with the same UX and no API behind it. Neither moves a peso. It's the same wires-cut pattern I use in the admin panel demo: the real interface, the backend disconnected.