
Integración de Conekta en Node: tarjetas, OXXO y SPEI con webhooks que no mienten (2026)
La Orders API de Conekta cobra tarjetas de forma síncrona, pero OXXO y SPEI son asíncronos: la orden se crea pendiente y solo se confirma cuando el cliente paga, vía webhook. Conekta firma con un par de llaves RSA (no HMAC) en el header Digest. Verifícalo contra el raw body y entrega solo con un evento confirmado.
Por qué los webhooks de Conekta fallan de formas que los de Stripe no
Si ya cableaste Stripe, tu memoria muscular te va a traicionar aquí. Lo digo porque me pasó: llegué a Conekta esperando un whsec_…, un HMAC compartido y stripe.webhooks.constructEvent. Nada de eso existe en Conekta. Conekta firma sus webhooks con un par de llaves RSA, no con un secreto HMAC compartido. Tú te quedas con la llave pública, Conekta firma con su llave privada, y la firma viaja en el header Digest. Si verificas como si fuera Stripe, todo “compila” y nada funciona.
El bug caro, el que te cuesta dinero real, es otro: entregar el producto cuando creas la orden. Con tarjeta puedes salirte con la tuya porque el cargo se resuelve de inmediato. Pero OXXO y SPEI son asíncronos. Cuando creas una orden OXXO, Conekta te devuelve una referencia que el cliente paga después, en la tienda. Cuando creas una orden SPEI, te devuelve una CLABE a la que el cliente transfiere después, desde su banco. El dinero llega horas o días más tarde. La orden nace pending_payment, y la única señal confiable de que el cliente pagó es un webhook firmado. Entregar sobre la respuesta de creación es regalar producto.
Esta es la guía operativa que completa la historia de Stripe vs Mercado Pago vs Conekta: un flujo de tarjeta + OXXO + SPEI cuya fuente de verdad es un webhook verificado, no un redirect ni una respuesta de creación. He movido dinero real con esto en producción, así que voy a arrancar con los tropiezos que los docs no te cuentan.
El modelo de Orders + cargos: una API, tres métodos de pago
Conekta (API v2) gira alrededor de una Orders API. Una Order empaqueta tres cosas: line_items (qué vendes), customer_info (a quién) y charges (cómo te pagan). Dentro del charge, payment_method.type decide el método: tarjeta, efectivo → OXXO, o transferencia → SPEI. Ese string de tipo es el detalle operativo de toda la integración.
Los SDK oficiales se publican como el paquete conekta, tanto para Node (npm) como para Python (pip). Así inicializas el cliente de Node con tu llave privada y creas una orden. Trato la forma exacta del request como ilustrativa donde el SDK abstrae detalles — confirma los nombres de campo en los docs de Orders:
import { Configuration, OrdersApi } from 'conekta'
const config = new Configuration({ accessToken: process.env.CONEKTA_PRIVATE_KEY })
const ordersApi = new OrdersApi(config)
// Crear una Order con cargo OXXO (efectivo). NO significa "pagado".
const { data: order } = await ordersApi.createOrder({
currency: 'MXN',
customer_info: { name: 'Cesar Ayala', email: 'cesar@example.com', phone: '+525500000000' },
line_items: [
{ name: 'Plan Pro (1 mes)', unit_price: 49900, quantity: 1 } // precios en centavos
],
metadata: { internal_ref: 'sub_1042', user_id: 'u_88' }, // tu llave para conciliar después
charges: [
{
payment_method: {
type: 'oxxo', // OXXO efectivo; string verificado en los docs de Orders
expires_at: Math.floor(Date.now() / 1000) + 3 * 24 * 60 * 60 // ventana de 3 días
}
}
]
}, {
headers: { 'Accept-Language': 'es' },
// idempotency key: que un reintento no genere dos vouchers
// (revisa el header de idempotencia vigente en la doc de tu versión de SDK)
})
Pon una idempotency key en cada creación de orden. Las redes fallan, tu cliente reintenta, y sin idempotencia terminas emitiendo dos referencias OXXO o cobrando dos tarjetas por el mismo carrito. Sobre los valores de payment_method.type: para efectivo es oxxo, para transferencia es spei. Para tarjeta hay un matiz que importa — al crear envías el cargo con el token de la tarjeta; en la respuesta el payment_method reporta credit o debit según el tipo de plástico. Confirma el string exacto de creación en los docs de tu versión, porque ahí está todo el juego.
Tarjetas: el único método que se resuelve de forma síncrona
La tarjeta es el caso fácil, y el único donde la respuesta de creación te dice el resultado. La regla de oro: nunca toques el PAN en tu servidor. Tokenizas en el navegador con Conekta.js, mandas el token, y tu servidor jamás ve el número de tarjeta. Eso mantiene tu alcance de PCI al mínimo.
// En el navegador (Conekta.js): tokeniza, obtén tok_..., mándalo a tu backend.
// En tu backend, ese token va dentro del payment_method del cargo.
// Al CREAR mandas el token; en la RESPUESTA verás type 'credit' o 'debit'.
charges: [
{ payment_method: { token_id: 'tok_xxxxxxxx' } } // confirma la forma exacta en los docs
]
Cuando creas una orden de tarjeta, el status del charge vuelve en la respuesta: paid, declined, o un estado intermedio si hay 3DS. Maneja los rechazos y el 3DS como estados del charge, no como errores HTTP — un rechazo es una respuesta válida con status: 'declined', no un 500.
Y aún con tarjeta, escucha charge.paid / order.paid. ¿Por qué, si ya tienes el resultado? Para mantener una sola ruta de fulfillment. Si tu código entrega solo sobre webhook —para tarjeta, OXXO y SPEI por igual— eliminas una clase entera de bugs de “entregué dos veces” o “entregué y el cargo se reversó”.
Tarjeta (síncrono) vs OXXO/SPEI (asíncrono): cuándo llega realmente el dinero
Tarjeta — síncrono
- El status del charge vuelve en la respuesta de creación
- paid / declined / 3DS se resuelven ahora
- Aun así reconcilia contra order.paid
- La respuesta SÍ es prueba de pago
OXXO / SPEI — asíncrono
- Devuelve referencia (OXXO) o CLABE (SPEI)
- La orden nace pending_payment
- El cliente paga en minutos, horas o días
- Solo el webhook confirma — la respuesta NO es prueba
OXXO y SPEI: la orden se crea pendiente — el cliente paga después
Aquí vive el corazón asíncrono y el error más caro. Cuando creas una orden OXXO (type: 'oxxo'), Conekta te devuelve un voucher con una referencia/código de barras que el cliente paga en la tienda. Esa referencia expira según el expires_at que mandaste. Cuando creas una orden SPEI (type: 'spei'), te devuelve una CLABE a la que el cliente transfiere desde su app bancaria.
En ambos casos la orden vuelve con payment_status: 'pending_payment'. La respuesta de creación NO es prueba de pago. Es prueba de que existe una referencia para que el cliente pague. Nada más. (Ojo: el status a nivel charge puede leerse distinto del payment_status de la orden — el campo autoritativo para decidir si entregas es el payment_status de la orden, confirmado en paid tras un fetch.)
La confirmación llega después, como webhook: order.paid cuando la orden se liquida, charge.paid a nivel cargo. Los flujos de aprobación dinámica (efectivo/transferencia recurrentes) también pueden emitir eventos inbound_payment.lookup / inbound_payment.payment_attempt — confirma cuáles aplican a tu integración en los docs de eventos. Lo único que debes hacer al crear la orden es mostrarle al cliente la referencia o la CLABE y decirle la ventana de pago (“paga antes del viernes en cualquier OXXO”). No otorgues acceso, no envíes producto, no actives la suscripción. Todavía no pagó.
// Tras crear la orden OXXO, muestra la referencia — NO entregues nada.
const oxxo = order.charges.data[0].payment_method
return res.json({
reference: oxxo.reference, // el código que el cliente paga en OXXO
barcode_url: oxxo.barcode_url, // confirma los nombres exactos en los docs
expires_at: oxxo.expires_at,
message: 'Paga en cualquier OXXO antes de la fecha de expiración.'
})
Verificar el webhook: firma RSA sobre el raw body (no HMAC)
Esta es la parte técnica que los docs dejan delgada operativamente. En tu dashboard de Conekta generas un par de llaves RSA. Te quedas con la llave pública; Conekta firma cada webhook calculando un SHA256 del cuerpo del request (UTF-8) y firmándolo con su llave privada. La firma llega en base64 en el header Digest. Tú verificas esa firma con tu llave pública contra el raw body exactamente como llegó.
El ejemplo oficial de los docs usa la librería NodeRSA (publicKey.verify(payload, signature, 'utf8', 'base64')). El módulo nativo crypto es una alternativa equivalente: verifica la misma firma RSA-SHA256 sobre los mismos bytes crudos. Aquí lo crítico —literal— es que req.body debe ser el Buffer crudo, no un objeto ya parseado. Para eso usas express.raw:
import express from 'express'
import crypto from 'crypto'
const PUBLIC_KEY = process.env.CONEKTA_WEBHOOK_PUBLIC_KEY // la llave pública del dashboard
const app = express()
// Captura el raw Buffer ANTES de cualquier middleware JSON. Esto es lo crítico.
app.post('/webhooks/conekta', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['digest'] // firma RSA en base64
const rawBody = req.body // Buffer crudo, byte por byte como llegó
// Equivalente nativo al ejemplo NodeRSA de los docs: RSA-SHA256 sobre los bytes crudos.
const verifier = crypto.createVerify('RSA-SHA256')
verifier.update(rawBody) // verifica contra los bytes EXACTOS
const valid = verifier.verify(PUBLIC_KEY, signature, 'base64')
if (!valid) {
console.warn('Firma de webhook invalida — descartando')
return res.status(401).send('invalid signature')
}
// Solo DESPUÉS de verificar, parsea.
const event = JSON.parse(rawBody.toString('utf8'))
if (event.type === 'order.paid') {
const ref = event.data?.object?.metadata?.internal_ref
// dedupe por event.id, confirma estado, ENTONCES entrega — ver la función de fulfillment
fulfillOnce(event.id, ref)
}
return res.status(200).send('ok')
})
Para depurar fuera de Node, el equivalente con OpenSSL es verificar el archivo de firma decodificado de base64 contra el cuerpo crudo con openssl dgst -sha256 -verify public.pem -signature sig.bin body.raw. Si eso pasa y tu código no, tu código está alterando los bytes. Cita: autenticación de webhooks de Conekta.
Flujo end-to-end: de crear la orden a entregar sobre un webhook verificado
El truco de Node que rompe en silencio toda verificación de firma
Aquí está el fallo número uno, el que te va a costar una tarde. Si un middleware como body-parser o express.json() ya corrió, tu req.body es un objeto parseado. Si lo vuelves a serializar con JSON.stringify() para verificar, JavaScript reordena las llaves y cambia el whitespace. Los bytes resultantes ya no son los que Conekta firmó. La verificación RSA falla con la llave correcta — y falla en silencio, como un genérico “invalid signature” que te manda a perseguir el problema equivocado.
// MAL — re-serializar cambia los bytes, la firma nunca cuadra
const event = req.body // ya parseado por express.json()
const bytes = Buffer.from(JSON.stringify(event)) // llaves reordenadas, whitespace distinto
verifier.verify(PUBLIC_KEY, signature, 'base64') // falla con la llave correcta
// BIEN — verifica el Buffer crudo, parsea solo después
verifier.update(req.body) // req.body es el Buffer de express.raw
const ok = verifier.verify(PUBLIC_KEY, signature, 'base64')
const event = ok ? JSON.parse(req.body.toString('utf8')) : null
La solución: captura el Buffer crudo antes de cualquier middleware JSON, verifica contra él, y parsea solo después de que la firma pase. Es exactamente la misma disciplina de raw body que en Stripe — si ya leíste cómo verificar la firma de webhook de Stripe, el principio se transfiere; solo cambia HMAC por RSA.
El resto del checklist de confiabilidad:
- Allowlist de IP. Conekta envía sus webhooks desde IP fijas. Si tienes firewall, permite
52.200.151.182,52.72.53.105y186.28.176.85(confirma los valores vigentes en tu dashboard antes de hardcodear; Conekta también suele documentar puertos como 80 y 443). - Deduplica por
event.id. Los webhooks se reentregan. Si procesas el mismoorder.paiddos veces sin dedupe, entregas dos veces. - Entrega idempotente. Verifica la firma → confirma con un fetch que la orden está
paid→ entonces otorga acceso. Si el evento ya se procesó, no hagas nada.
Trampas de confiabilidad de los webhooks de Conekta
Conciliación: emparejar órdenes pendientes con los pagos que llegan
Como OXXO y SPEI pagan de forma asíncrona, necesitas un proceso de conciliación. No es opcional: es la diferencia entre “el cliente pagó y no le di acceso” y un sistema que cuadra solo.
Primero, guarda tu referencia interna en metadata al crear la orden (lo hice en el primer snippet con internal_ref y user_id). Así, cuando llega order.paid, el webhook puede mapear el pago de vuelta al cliente correcto sin adivinar.
Segundo, expira las órdenes pendientes que pasen su ventana. Una referencia OXXO vencida nunca se va a pagar; libera el inventario reservado y marca la orden como expirada.
Tercero, ten un fallback de polling. Los webhooks se pierden — un deploy, un timeout, una caída momentánea. Un job periódico que re-consulta el estado de las órdenes pending_payment recientes (GET de la orden) atrapa los pagos cuyo webhook no llegó. Y la regla que ata todo: trata el estado de la orden obtenido por fetch como la verdad — no el redirect, no la respuesta de creación. Si el fetch dice paid, pagó. Si dice pending_payment, no pagó, sin importar lo que diga la UI.
Este es el mismo razonamiento de fondo de webhooks vs polling vs API: el webhook es tu disparador en tiempo real, pero el fetch autoritativo es tu red de seguridad.
Integrar Conekta de punta a punta
- Init SDK + idempotenciaCliente con llave privada; idempotency key en cada creación de orden
- Tokeniza tarjetasConekta.js en el navegador; el PAN nunca toca tu servidor
- Crea la Orderline_items + customer_info + payment_method (token / oxxo / spei) + metadata
- Genera el par RSAEn el dashboard; guarda la llave pública para verificar
- Verifica sobre raw bodyexpress.raw + crypto.verify; parsea solo si la firma pasa
- Entrega sobre evento confirmadoorder.paid verificado → fetch → otorga acceso. Idempotente y deduplicado
- ConciliaExpira pendientes vencidas, polling de fallback, el fetch es la verdad
Preguntas frecuentes sobre integrar Conekta
¿Conekta usa un secreto HMAC como Stripe?
No. Conekta usa un par de llaves RSA. Generas el par en el dashboard, te quedas con la llave pública, y Conekta firma con la privada. La firma llega en el header Digest y la verificas con tu llave pública contra el raw body. El ejemplo de los docs usa NodeRSA; el módulo nativo crypto con RSA-SHA256 es equivalente.
Mi orden OXXO dice pendiente — ¿es un bug?
No. Las órdenes OXXO y SPEI nacen pending_payment y siguen así hasta que el cliente paga. La referencia o la CLABE es solo una invitación a pagar. Espera el webhook order.paid; ahí recién está pagado.
¿Por qué falla mi verificación de firma si tengo la llave correcta?
Casi siempre porque estás verificando un cuerpo re-serializado en vez de los bytes crudos. Si JSON.parse() y luego JSON.stringify() corrieron sobre el body, el reordenamiento de llaves cambió los bytes y la firma ya no cuadra. Verifica contra el Buffer de express.raw y parsea solo después.
¿Cuándo llega realmente el dinero en OXXO/SPEI? Es asíncrono: de minutos a días. El cliente decide cuándo va al OXXO o cuándo hace la transferencia SPEI. Solo el webhook confirmado te dice que entró.
¿Fees, límites e IP?
Tratables como sensibles al tiempo. Como en 2026, confirma los valores vigentes (fees, límites de transacción, ventanas de expiración y las IP de webhook 52.200.151.182, 52.72.53.105, 186.28.176.85) directamente en el dashboard y los docs de Conekta antes de hardcodear nada.
¿Conekta o Mercado Pago/Stripe para México? Depende de tu mezcla. Conekta brilla por su soporte nativo de OXXO y SPEI con un solo modelo de Orders. Comparo las tres a fondo en pasarelas de pago en México, y si vas por la otra grande, está cómo integrar Mercado Pago.
Lánzalo: la fuente de verdad es un webhook verificado
Una sola regla, si te llevas algo de aquí: nunca otorgues acceso sobre un redirect ni sobre una respuesta de creación — solo sobre un webhook pagado y verificado.
La tarjeta se resuelve en la respuesta. OXXO y SPEI se resuelven en el futuro, vía webhook. Verifica RSA contra el raw body (no HMAC, no re-stringify), allowlist las IP fijas, deduplica por event id, y mantén creación y fulfillment idempotentes. Concilia las órdenes pendientes con un fallback de polling y trata el estado obtenido por fetch como la verdad. Haz eso y tu integración no miente: el dinero que dices que entró, entró.
Construyo estas integraciones de pago para LATAM en producción —Conekta, Mercado Pago, Stripe— con dinero real moviéndose. Si necesitas que alguien te cablee OXXO y SPEI bien a la primera, escríbeme.