
Conekta Integration in Node: Cards, OXXO & SPEI With Webhooks That Don't Lie (2026)
Conekta's Orders API charges cards synchronously, but OXXO and SPEI are asynchronous: the order is created pending and only confirmed when the customer pays, via a webhook. Conekta signs webhooks with an RSA keypair (not HMAC) in the Digest header. Verify it against the raw body, then fulfill only on a confirmed event.
Why Conekta webhooks break in ways Stripe’s don’t
I wire payment integrations in Mexico for a living, and Conekta is where I watch the most engineers fall on their face — not because the API is hard, but because they bring Stripe muscle memory to a gateway that does two critical things completely differently.
Here’s the first one, and it’s the one that quietly breaks production: Conekta does not sign webhooks with an HMAC shared secret. If you’ve internalized the Stripe pattern — grab the Stripe-Signature header, HMAC the raw body with your endpoint secret, compare — that exact reflex produces a working-looking handler that rejects every legitimate event. Conekta signs with an RSA key pair. You hold a public key; Conekta signs with its private key and ships the signature in the DIGEST header. Different math, different header, different mental model.
The second one costs real money: OXXO and SPEI orders are created pending and pay later. A card charge resolves in the create response — you know the outcome immediately. But an OXXO voucher is a barcode the customer walks into a convenience store to pay, and a SPEI order is a CLABE they wire to from their banking app. That payment lands hours or days later. If you fulfill on order creation — ship the goods, unlock the SaaS plan, grant access — you’ve given away product for an order that may never be paid. The voucher can simply expire.
So the whole integration comes down to one discipline: the source of truth is a verified webhook, never the redirect and never the create response. This is the engineer-grade guide that completes my Stripe vs Mercado Pago vs Conekta comparison — if that post helped you pick Conekta, this one gets you to production without the two bugs above. (If you landed on the other big Mexican gateway, I have the parallel Mercado Pago integration guide too.)
The Orders + charges model: one API, three payment methods
Conekta’s API v2 is built around one object: the Order. An Order bundles line_items (what’s being bought), customer_info (who’s buying), and charges (how they pay). The operative detail lives inside the charge: charge.payment_method.type selects card vs OXXO vs SPEI. One API, three payment methods, one switch.
The official SDKs are conekta for Node and conekta for Python. The modern Node SDK exposes an OrdersApi client you initialize with your private key:
import { Configuration, OrdersApi } from "conekta";
const config = new Configuration({ accessToken: process.env.CONEKTA_PRIVATE_KEY });
const ordersApi = new OrdersApi(config);
// Create an OXXO (cash) order — customer pays a voucher at the store later
const { data: order } = await ordersApi.createOrder(
{
currency: "MXN",
customer_info: { name: "Cesar Ayala", email: "buyer@example.com", phone: "+525555555555" },
line_items: [{ name: "Plan Pro", unit_price: 49900, quantity: 1 }], // cents: $499.00 MXN
charges: [{ payment_method: { type: "cash" } }], // "cash" => OXXO voucher
metadata: { internal_order_id: "ord_8f21" }, // map the payment back to YOUR record
},
undefined,
// idempotency key: a retry must not double-issue the voucher
{ headers: { "Accept-Language": "es", "X-Conekta-Idempotency": "ord_8f21-create" } }
);
The three payment_method.type strings you’ll use on the create request are card (a token), cash (the OXXO voucher), and spei (the CLABE). Confirm the current names in the Orders docs — note that the webhook payload nests a more specific type like oxxo/credit/debit inside the resolved charge object, so don’t be thrown when the inbound event reads differently from what you sent. One more disambiguation: on a direct charge the SPEI value is spei and the response returns the CLABE; if instead you use the hosted Checkout Component, the equivalent allowed_payment_methods entry is spelled bank_transfer. Same rail, two different identifiers depending on which surface you’re on.
Two non-negotiables on every create call:
- Amounts are integers in cents.
49900is $499.00 MXN, not $49,900. Get this wrong and you’ll charge 100x. - Put an idempotency key on every create request. Networks retry. Without idempotency, a retried OXXO create issues a second voucher with a different reference, and now you have two pending orders for one purchase.
The exact header/option shape for idempotency is illustrative above — the SDK abstracts request options, so confirm the current option name in the SDK version you install (the conekta-node repo is the authoritative source for the request-option shape).
Cards: the only payment method that resolves synchronously
The card path is the friendly one. You tokenize on the client with Conekta.js so the raw PAN never touches your server — that keeps you out of the worst of PCI scope. The browser hands you a token_id; you pass it as the card payment method:
const { data: order } = await ordersApi.createOrder({
currency: "MXN",
customer_info: { name: "Cesar Ayala", email: "buyer@example.com", phone: "+525555555555" },
line_items: [{ name: "Plan Pro", unit_price: 49900, quantity: 1 }],
charges: [{ payment_method: { type: "card", token_id: "tok_xxxxxxxx" } }],
});
// For cards ONLY, the outcome is in the response:
const status = order.charges.data[0].status; // e.g. "paid" / "declined"
Card is the one case where the create response tells you the outcome. The order.charges.data[0].status path matches the current Orders response schema, but field paths can drift between SDK versions — confirm the shape in your installed SDK and the Orders docs. Treat declines and 3DS as charge states, not HTTP errors — a declined card is usually a 200 with a charge status of declined, not an exception you can try/catch. And even here, I still listen for the order.paid / charge.paid webhook, because that keeps the card path consistent with the async paths: every order, regardless of method, becomes “really paid” only when a verified event says so. One code path to reason about beats two.
OXXO and SPEI: the order is created pending — the customer pays later
This is the heart of Conekta in Mexico, because cash and bank transfer are how a huge slice of the market actually pays.
When you create an OXXO order (type: "cash"), the response contains a reference/barcode the customer pays in any OXXO store. When you create a SPEI order (type: "spei"), the response contains a CLABE the customer transfers to from their bank. In both cases the order comes back pending. The reference existing is not proof of payment — it’s proof you asked for one.
The customer might pay in ten minutes or in two days. They might never pay, and the voucher expires. Confirmation arrives later, as a webhook — order.paid / charge.paid. For SPEI, dynamic-approval flows can also surface inbound_payment events as the transfer lands. So the correct UX is: show the customer the reference or CLABE, tell them the expiration window clearly, and grant nothing yet. You’re holding the order open, not completing it.
Synchronous vs asynchronous: when the money actually arrives
Card (synchronous)
- Token from Conekta.js then charge resolves in the create response
- Charge status is paid / declined immediately
- Fulfill-on-response is technically possible — but still reconcile via webhook
- Declines and 3DS are charge states, not HTTP errors
OXXO / SPEI (asynchronous)
- Create returns an OXXO reference or a SPEI CLABE
- Order comes back pending — NOT paid
- Customer pays hours-to-days later at a store or via bank transfer
- Only a verified order.paid webhook confirms the money arrived
Verifying the webhook: RSA signature on the raw body (not HMAC)
Now the part the docs leave operationally thin. Conekta signs a SHA256 of the request body with its RSA private key, and sends that signature base64-encoded in the DIGEST header. You verify it with the public key you generated once in the Conekta dashboard.
Setup is one-time: in Conekta you generate an RSA key pair, Conekta keeps the private key to sign, and you keep the public key to verify. Store that public key as an env var (PEM format).
The verification itself, using Node’s built-in crypto — no extra library needed — against the raw request body:
import crypto from "node:crypto";
import express from "express";
const app = express();
const CONEKTA_PUBLIC_KEY = process.env.CONEKTA_WEBHOOK_PUBLIC_KEY; // PEM, from the dashboard
// CRITICAL: express.raw gives you req.body as a Buffer — the exact bytes Conekta signed.
app.post("/webhooks/conekta", express.raw({ type: "application/json" }), (req, res) => {
const signature = Buffer.from(req.get("Digest") || "", "base64");
const rawBody = req.body; // a Buffer, NOT a parsed object
const verifier = crypto.createVerify("RSA-SHA256");
verifier.update(rawBody); // verify against raw bytes
verifier.end();
const valid = verifier.verify(CONEKTA_PUBLIC_KEY, signature);
if (!valid) return res.status(401).send("invalid signature");
// Only NOW is it safe to parse:
const event = JSON.parse(rawBody.toString("utf8"));
handleEvent(event).catch(console.error);
return res.status(200).send("ok"); // ack fast; do work async
});
For debugging, the OpenSSL equivalent is handy: base64-decode the DIGEST value to a binary signature, then openssl dgst -sha256 -verify pubkey.pem -signature signature.sha256 payload.json. One caution that bites people here: the payload.json file must contain the exact UTF-8 bytes Conekta sent, with no added trailing newline — write it raw from the captured body, not by pasting into an editor, or you’ll reproduce the very byte-mismatch failure described in the next section. If OpenSSL verifies but your code doesn’t, the problem is almost always your body bytes. See the Conekta webhook auth docs for the canonical reference. This is the same raw-body discipline I wrote up for Stripe signature failures — different crypto, identical failure mode.
End-to-end Conekta payment flow
The Node gotcha that silently breaks every signature check
Here’s the single most common Conekta bug I get called in to fix, and it’s brutal because it fails silently — right key, right algorithm, and the check still returns false.
The cause: somewhere upstream, express.json() or a global body-parser already ran. It did JSON.parse() on the body. Then your handler does JSON.stringify(req.body) to “rebuild” it for verification. But re-stringifying reorders keys and changes whitespace — {"a":1,"b":2} may come back as {"b":2,"a":1} with different spacing. RSA signs bytes, not meaning. Different bytes, invalid signature. Every time.
The fix is structural, not clever: capture the raw Buffer before any JSON middleware touches it, verify against that, and parse only after the signature passes. That’s exactly why the handler above mounts express.raw({ type: "application/json" }) on the webhook route specifically. If you have a global app.use(express.json()), register the raw route before it, or scope the JSON parser so it never runs on /webhooks/conekta.
The rest of the reliability checklist:
- Allowlist the source IPs. Conekta sends webhooks from a fixed set of IPs —
52.200.151.182,52.72.53.105, and186.28.176.85. If you firewall your endpoint, allow all three (and note Conekta only delivers to ports 80, 443, or the 1025–10001 range). Confirm the current IPs and ports in the webhook configuration docs, as of 2026. - Dedupe on the event id. Webhooks get redelivered. Store processed event ids and skip duplicates so you don’t fulfill twice.
- Fulfill-only-on-webhook. Verify the signature, then fetch the order and confirm it’s actually paid, then grant access or ship. Never act on the event payload alone, and never on the redirect.
Webhook reliability traps to avoid
Reconciliation: matching pending orders to the payments that arrive
Because OXXO and SPEI pay asynchronously, “create and forget” isn’t an option — you need a small reconciliation loop. This is the operational close-out that keeps your books honest.
Three jobs do it:
-
Map payment back to your record. Put your internal order id in the order’s
metadataat create time (you sawinternal_order_idabove). Whenorder.paidarrives, read that field to find the customer and complete fulfillment. Without it, you’re guessing which pending order a payment belongs to. -
Expire the stragglers. OXXO and SPEI references have a window. When it passes unpaid, mark your order expired and release any reserved inventory so you’re not holding stock for a sale that won’t happen.
-
Have a fallback poll. Webhooks get missed — endpoint down, deploy in flight, queue dropped. For pending orders, run a periodic job that re-fetches the order status from Conekta and reconciles anything the webhook missed. This is the polling safety net underneath the event-driven path; I unpack the tradeoff in webhooks vs polling vs API.
The rule throughout: the verified, fetched order status is truth. Not the redirect the customer landed on, not the create response, not even the raw webhook payload — the order you re-read and confirmed as paid.
Integrate Conekta end to end
- Init SDK + idempotencyOrdersApi with your private key; idempotency key on every create
- Tokenize cardsConekta.js in the browser so raw PAN never hits your server
- Create the Orderline_items + customer_info + payment_method: card / cash / spei
- Generate RSA keypairOnce, in the dashboard; you hold the public key
- Verify on raw bodycrypto.verify RSA-SHA256 against the exact bytes received
- Fulfill on confirmed paidFetch the order, confirm paid, grant access — idempotently
- ReconcileExpire unpaid windows; poll for missed events; release inventory
Conekta integration FAQ
Does Conekta use an HMAC secret like Stripe?
No. Conekta uses an RSA key pair. You generate it once in the dashboard, keep the public key, and verify the base64 signature in the DIGEST header against the raw body with SHA256-RSA. There is no shared secret to HMAC.
My OXXO order says pending — is that a bug?
No. OXXO and SPEI orders are born pending. The order stays pending until the customer actually pays — at the store or by SPEI transfer — and Conekta confirms it via the order.paid webhook. Pending means “voucher issued, not yet paid.” Wait for the event.
Why does my signature check fail even with the right public key?
You’re almost certainly verifying a re-stringified body instead of the raw bytes. If a JSON parser ran and you did JSON.stringify(req.body), key reordering and whitespace changed the bytes. Verify against the raw Buffer captured before any JSON middleware.
When does the money actually arrive for OXXO/SPEI? Asynchronously — anywhere from minutes to a couple of days. The create response never confirms payment; only the verified, confirmed-paid webhook does. Build for the delay.
What are the fees, limits, and the webhook IPs?
Treat all of these as time-sensitive. As of 2026, Conekta sends webhooks from 52.200.151.182, 52.72.53.105, and 186.28.176.85 over ports 80, 443, or 1025–10001 — but confirm the current IPs, ports, fees, and voucher expiration windows in the Conekta dashboard and docs before you go live.
Ship it: the source of truth is a verified webhook
If you remember one thing from this post, make it this: never grant access on a redirect or a create response — only on a verified, confirmed-paid webhook. That single rule prevents the two expensive Conekta bugs at once.
The whole integration in four lines:
- Cards resolve in the response; OXXO and SPEI resolve in the future via webhook. Treat both as “really paid only when an event says so.”
- Verify the RSA signature against the raw body — Node’s
crypto.verify('RSA-SHA256', …), theDIGESTheader, your public key, the exact bytes. - Allowlist all three IPs, dedupe event ids, keep create and fulfill idempotent.
- Reconcile pending and expired orders with a fallback poll so a missed webhook never becomes a lost sale or a leaked product.
I build these LATAM payment integrations in production — real money moving through Conekta, Mercado Pago, and Stripe for Mexican clients. If you want a Conekta integration that doesn’t lie about what’s paid, reach out and let’s wire it correctly the first time.