Pagos QR con checkout api
El Checkout API te permite construir tu propia terminal de pago con código QR — ya sea para puntos de venta, kioscos de autoservicio o cualquier pantalla de pago dentro de tu aplicación — sin depender de Conekta Go. Tu sistema crea un checkout, extrae la URL de pago de la respuesta, la convierte en un código QR que muestra en cualquier pantalla, y escucha la confirmación de pago a través de webhooks. El cliente escanea el QR, paga en la página hosteada de Conekta, y tu backend recibe el evento order.paid para finalizar la transacción. Usa este enfoque cuando necesites integrar flujos de pago dentro de tu propia aplicación con control total sobre la interfaz, la marca y el ciclo de vida de cada sesión. Si en cambio quieres un link de pago listo para compartir sin necesidad de renderizar el QR tú mismo, consulta Generar links de pago.
Cómo funciona
- Tu sistema solicita un checkout — tu backend llama a
POST /checkoutscon el monto de la transacción, los métodos de pago permitidos y un tiempo de expiración adecuado para pago presencial. - Conekta devuelve la URL de pago — el campo
urlde la respuesta es la página de pago hosteada. Este es el valor que conviertes en código QR. - Tu app muestra el QR — usa cualquier librería estándar (ver Paso 2) para generar un código escaneable a partir de la
url. El cliente no necesita escribir nada. - El cliente paga desde su teléfono — escanea el QR, llega a la página de pago de Conekta, elige su método (tarjeta, SPEI, efectivo, etc.) y completa la transacción.
- Tu backend recibe el webhook — una vez que el pago es exitoso, Conekta envía un evento
charge.paida tu endpoint configurado. Esta es la señal para marcar la orden como pagada en tu sistema.
Requisitos previos
Antes de comenzar necesitas:
- Una cuenta en Conekta con tu llave privada de API. Para pruebas, usa tu llave de modo de pruebas.
- Un servidor backend capaz de hacer peticiones HTTPS a
api.conekta.io. El Checkout API debe llamarse desde el servidor — nunca expongas tu llave privada en un frontend o una app móvil. - Un endpoint de webhook registrado. Configúralo en el panel de Conekta antes de salir a producción. Consulta Configurar un webhook.
- Familiaridad con la verificación de firma de webhooks. Todo manejador de webhook debe verificar el header
DIGEST— consulta Autenticación de webhooks.
Paso 1: Crear el checkout
Desde tu backend, envía un POST a /checkouts cada vez que un cliente inicia una transacción. Cada transacción tiene su propio checkout — esto hace que cada QR sea de un solo uso y evita que un código ya utilizado pueda escanearse nuevamente.
Endpoint
POST https://api.conekta.io/checkouts
Accept: application/vnd.conekta-v2.2.0+json
Content-Type: application/json
Authorization: Bearer TU_LLAVE_PRIVADA
Cuerpo de la petición
{
"name": "Pago Terminal #1 - Mesa 4",
"type": "PaymentLink",
"recurrent": false,
"expires_at": 1750383000,
"allowed_payment_methods": ["card", "bank_transfer", "cash"],
"order_template": {
"currency": "MXN",
"line_items": [
{
"name": "Café Americano",
"unit_price": 8000,
"quantity": 2
},
{
"name": "Agua mineral",
"unit_price": 3500,
"quantity": 1
}
],
"customer_info": {
"name": "Cliente en tienda",
"email": "[email protected]",
"phone": "+525512345678"
}
}
}Parámetros clave
| Parámetro | Tipo | Requerido | Descripción |
|---|---|---|---|
name | string | Sí | Identifica este checkout en tu panel de Conekta. Usa algo significativo como el ID de la terminal o el número de orden. |
type | string | Sí | Siempre "PaymentLink" para este flujo. |
recurrent | boolean | Sí | false para QR de un solo uso (recomendado para punto de venta). true si quieres que el mismo QR acepte múltiples pagos (ej. pantalla fija en un kiosco). |
expires_at | integer | Sí | Timestamp Unix cuando expira este QR. Para uso presencial, 15–30 minutos es una ventana razonable. Para un kiosco estático, puedes extenderlo a horas o días. |
allowed_payment_methods | array | Sí | Métodos disponibles para el cliente. Valores válidos: "card", "bank_transfer" (SPEI), "cash" (OXXO / Conekta Efectivo). |
order_template.line_items[].unit_price | integer | Sí | Monto en centavos. 8000 = $80.00 MXN. |
El tiempo de expiración debe calcularlo tu servidorSiempre calcula
expires_aten tu backend. Nunca uses un timestamp proveniente del dispositivo cliente — puede ser manipulado. Un patrón seguro esMath.floor(Date.now() / 1000) + (20 * 60)(20 minutos a partir de ahora).
Respuesta
{
"id": "checkout_2tXx9jDpCqIrjVKZ",
"object": "checkout",
"name": "Pago Terminal #1 - Mesa 4",
"slug": "a1b2c3d4e5f6",
"url": "https://pay.conekta.com/link/a1b2c3d4e5f6",
"type": "PaymentLink",
"status": "Issued",
"livemode": false,
"recurrent": false,
"expires_at": 1750383000,
"allowed_payment_methods": ["card", "bank_transfer", "cash"],
"monthly_installments_enabled": false,
"needs_shipping_contact": false
}El campo que necesitas es url — esta es la página de pago a la que llega el cliente al escanear el QR. Guarda también el id y el expires_at para manejar la expiración (ver Paso 4).
Paso 2: Generar y mostrar el código QR
Conekta devuelve la url — tu aplicación genera la imagen del QR a partir de ella. Usa cualquier librería estándar:
Node.js
npm install qrcodeconst QRCode = require('qrcode');
async function generarQRPago(checkoutUrl) {
// Devuelve un data URI — incrústalo directamente en un <img> o canvas
const qrDataUrl = await QRCode.toDataURL(checkoutUrl, {
width: 400,
margin: 2,
color: {
dark: '#1A1A2E',
light: '#FFFFFF'
}
});
return qrDataUrl;
}Python
pip install qrcode[pil]import qrcode
from io import BytesIO
import base64
def generar_qr_pago(checkout_url: str) -> str:
qr = qrcode.QRCode(version=1, box_size=10, border=2)
qr.add_data(checkout_url)
qr.make(fit=True)
img = qr.make_image(fill_color="#1A1A2E", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode()Recomendaciones de display para terminales físicas:
- Tamaño mínimo del QR: 300 × 300 px para escaneo cómodo a 30–50 cm de distancia.
- Muestra una cuenta regresiva de expiración junto al QR para que el cajero sepa cuándo regenerarlo.
- Si usas
recurrent: false, retira el QR de pantalla inmediatamente después de recibirorder.paidpara evitar escaneos accidentales dobles.
Paso 3: Recibir la confirmación de pago por webhooks
Cuando el cliente completa el pago, Conekta envía un POST a tu endpoint de webhook. Tu servidor debe procesar este evento y notificar a tu terminal que la transacción está completada.
Registra tu endpoint
Configura la URL de tu webhook en el panel de Conekta. También puedes registrarlo programáticamente mediante el API de Webhooks.
Eventos a manejar
| Evento | Cuándo se dispara | Acción recomendada |
|---|---|---|
order.paid | La orden completa fue liquidada — usa este como señal principal de pago | Marcar la orden como pagada, notificar a la terminal |
charge.declined | El intento de pago fue rechazado (fondos insuficientes, tarjeta bloqueada, etc.) | Registrar el rechazo, mostrar mensaje al cliente |
charge.canceled | El cargo fue cancelado antes de completarse | Limpiar el estado pendiente en tu sistema |
¿Por quéorder.paidy nocharge.paid?
order.paides el evento que confirma que la transacción está completamente liquidada desde la perspectiva del negocio. Es el evento más confiable para disparar lógica de negocio como confirmar órdenes, enviar recibos o notificar a la terminal. Usacharge.paidsolo si necesitas detalles específicos del cargo individual.
Payload del webhook — order.paid
order.paid{
"id": "evt_2tXx9jDpCqIrjABC",
"object": "event",
"type": "order.paid",
"livemode": false,
"created_at": 1750382100,
"data": {
"object": {
"id": "ord_2tXx9jDpCqIrjMNO",
"object": "order",
"status": "paid",
"amount": 19500,
"currency": "MXN",
"livemode": false,
"created_at": 1750382080,
"customer_info": {
"name": "Cliente en tienda",
"email": "[email protected]"
},
"line_items": {
"object": "list",
"data": [
{ "name": "Café Americano", "unit_price": 8000, "quantity": 2 },
{ "name": "Agua mineral", "unit_price": 3500, "quantity": 1 }
]
},
"charges": {
"object": "list",
"data": [
{
"id": "ch_2tXx9jDpCqIrjXYZ",
"status": "paid",
"amount": 19500,
"payment_method": {
"type": "card",
"object": "card_payment",
"service_name": "Visa"
}
}
]
}
},
"previous_attributes": {}
},
"webhook_status": "successful"
}Campos clave a extraer:
type— confirma que es un eventoorder.paiddata.object.id— el ID de la orden; correlaciona con el checkout que creaste (guarda el mapeocheckout.id → order.idal crear el checkout)data.object.amount— verifica que coincide con el monto esperado en tu sistemadata.object.status— debe ser"paid"antes de marcar la transacción como completadadata.object.charges.data[0].payment_method— método con el que pagó el cliente, útil para el recibo
Verifica la firma del webhook — obligatorio
Cada petición que Conekta envía incluye un header DIGEST con una firma RSA-SHA256. Siempre verifica este header antes de procesar el payload — un webhook sin verificar puede ser falsificado.
const NodeRSA = require('node-rsa');
// Inicializa una sola vez al arrancar con la llave pública de tu compañía
// Obtenla desde Conekta: GET /webhooks/keys (ver doc:autenticación-webhooks)
const llavePublica = new NodeRSA(process.env.CONEKTA_WEBHOOK_PUBLIC_KEY);
function verificarFirmaWebhook(cuerpoRaw, headerDigest) {
try {
return llavePublica.verify(cuerpoRaw, headerDigest, 'utf8', 'base64');
} catch {
return false;
}
}
// Handler Express
app.post('/webhooks/conekta', express.raw({ type: 'application/json' }), (req, res) => {
const digest = req.headers['digest'];
if (!verificarFirmaWebhook(req.body, digest)) {
return res.status(401).send('Firma inválida');
}
const evento = JSON.parse(req.body);
if (evento.type === 'order.paid') {
const orden = evento.data.object;
// Usa una llave de idempotencia para no procesar el mismo evento dos veces
procesarPago(evento.id, orden.id, orden.amount);
}
res.status(200).send('OK');
});
La idempotencia es obligatoria en tu webhook handlerConekta reintenta webhooks que no reciben una respuesta 2xx — ver Reintentos de notificación. Guarda el
event.idy omite el procesamiento si ya lo manejaste. Sin esto, un problema de red durante tu respuesta puede hacer que el mismo pago se procese dos veces.
Security Review RequiredEsta sección documenta el manejo de webhooks. Confirmar antes de publicar:
- Que el nombre exacto del header (
DIGEST) coincide con el comportamiento en producción.- Que el endpoint de llave pública (
GET /webhooks/keys) existe y está documentado endoc:autenticación-webhooks.
No publicar hasta que revisen: equipo de ingeniería / seguridad
Para la guía completa de verificación de firma, consulta Autenticación de webhooks.
Paso 4: Manejar expiración y errores
QR expirado
Un checkout con status: "Expired" (pasado su expires_at) ya no puede aceptar pagos. El cliente verá una página de error si lo escanea.
Estrategia de detección: consulta GET /checkouts/{id} periódicamente, o usa tu expires_at local para saber cuándo caduca. Si el tiempo se agota antes de que el cliente pague, crea un nuevo checkout y muestra un QR fresco.
async function renovarSiExpirado(checkout) {
const ahoraUnix = Math.floor(Date.now() / 1000);
if (checkout.expires_at < ahoraUnix + 60) {
// Menos de 60 segundos restantes — regenera de forma proactiva
return await crearNuevoCheckout(checkout.datosOrden);
}
return checkout;
}
No reutilices IDs de checkouts expiradosCada transacción debe iniciar con una nueva llamada a
POST /checkouts. No existe un endpoint para extender la expiración de un checkout existente.
Errores comunes
| HTTP Status | Error | Causa probable | Solución |
|---|---|---|---|
401 | Unauthorized | Llave incorrecta o ausente | Confirma que estás usando la llave privada, no la pública |
402 | Payment Required | Problema con la cuenta | Revisa el estado de tu cuenta en Conekta |
422 | Unprocessable Entity | Cuerpo de petición inválido | Verifica expires_at (debe ser un timestamp futuro), unit_price (entero positivo en centavos) y campos requeridos |
500 | Server Error | Problema en Conekta | Reintenta con backoff exponencial; no reintentes de inmediato |
Para el catálogo completo, consulta Códigos de error HTTP.
Ejemplo completo: integración POS en Node.js
Este ejemplo muestra el flujo completo — crear checkout → generar QR → webhook handler — tal como lo conectarías en un servidor Express que respalda una terminal.
const express = require('express');
const QRCode = require('qrcode');
const NodeRSA = require('node-rsa');
const app = express();
const CONEKTA_PRIVATE_KEY = process.env.CONEKTA_PRIVATE_KEY;
const CONEKTA_API_URL = 'https://api.conekta.io';
// --- Crear checkout y devolver el QR como data URI ---
async function crearQRPago(datosOrden) {
const expiresAt = Math.floor(Date.now() / 1000) + (20 * 60); // 20 minutos
const respuesta = await fetch(`${CONEKTA_API_URL}/checkouts`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CONEKTA_PRIVATE_KEY}`,
'Accept': 'application/vnd.conekta-v2.2.0+json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: `Terminal-01 Orden ${datosOrden.ordenId}`,
type: 'PaymentLink',
recurrent: false,
expires_at: expiresAt,
allowed_payment_methods: ['card', 'bank_transfer', 'cash'],
order_template: {
currency: 'MXN',
line_items: datosOrden.articulos.map(item => ({
name: item.nombre,
unit_price: item.precioEnCentavos,
quantity: item.cantidad
}))
}
})
});
if (!respuesta.ok) {
const error = await respuesta.json();
throw new Error(`Fallo al crear checkout: ${JSON.stringify(error)}`);
}
const checkout = await respuesta.json();
// Guarda el mapeo para correlacionar el webhook con esta transacción
await db.guardarCheckout({
checkoutId: checkout.id,
ordenId: datosOrden.ordenId,
expiresAt: checkout.expires_at,
montoEsperado: datosOrden.totalEnCentavos
});
// Genera el QR a partir de la URL de pago
const qrDataUri = await QRCode.toDataURL(checkout.url, { width: 400 });
return {
checkoutId: checkout.id,
qrDataUri,
expiresAt: checkout.expires_at,
urlPago: checkout.url
};
}
// --- Endpoint de webhook ---
const llavePublicaWebhook = new NodeRSA(process.env.CONEKTA_WEBHOOK_PUBLIC_KEY);
const eventosYaProcesados = new Set(); // En producción, usar Redis o base de datos
app.post('/webhooks/conekta', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Verifica la firma
const digest = req.headers['digest'];
const esValido = llavePublicaWebhook.verify(req.body, digest, 'utf8', 'base64');
if (!esValido) {
return res.status(401).send('Firma inválida');
}
const evento = JSON.parse(req.body);
// 2. Idempotencia — Conekta reintenta si no recibe 2xx
if (eventosYaProcesados.has(evento.id)) {
return res.status(200).send('Ya procesado');
}
eventosYaProcesados.add(evento.id);
// 3. Procesa el evento
if (evento.type === 'order.paid') {
const orden = evento.data.object;
// 4. Verifica el monto en el servidor — nunca confíes en montos del cliente
const stored = await db.getCheckoutPorOrden(orden.id);
if (stored && stored.montoEsperado !== orden.amount) {
console.error(`Discrepancia de monto en orden ${orden.id}`);
return res.status(200).send('OK'); // Devuelve 200 — registra e investiga
}
// 5. Notifica a tu terminal (WebSocket, SSE o endpoint de polling)
await notificarTerminal(orden.id, 'pagado');
}
res.status(200).send('OK');
});
// --- Endpoint de polling para la terminal (si no usas WebSockets) ---
app.get('/terminal/estado-orden/:ordenId', async (req, res) => {
const estado = await db.getEstadoOrden(req.params.ordenId);
res.json({ estado });
});
app.listen(3000);Pruebas en modo de pruebas
Usa tu llave de modo de pruebas para recorrer el flujo completo sin cargos reales.
- Crea un checkout con la llave de pruebas — la estructura de respuesta es idéntica a producción.
- Abre la
urlen un navegador (no necesitas escanearla desde un teléfono). Llegarás a la página de pago de Conekta en modo de pruebas. - Completa un pago de prueba usando los datos de Tarjetas de prueba (para tarjeta) o simula una notificación SPEI desde Pagos en modo de pruebas (para transferencia bancaria).
- Recibe el webhook — usa ngrok para exponer tu servidor local y recibir eventos
order.paidreales durante el desarrollo.
El endpoint es el mismo para pruebas y producciónConekta usa la misma URL
api.conekta.iopara ambos ambientes. Lo que cambia es el prefijo de la llave de API: las llaves de pruebas producenlivemode: falseen todas las respuestas. Nunca escribas una llave directamente en el código — cárgala siempre desde variables de entorno.
Relacionado
- Generar links de pago — la versión simplificada de este flujo si solo necesitas una URL compartible sin renderizar tu propio QR
- Configurar un webhook — configura tu endpoint antes de salir a producción
- Autenticación de webhooks — lectura obligatoria antes de publicar cualquier webhook handler
- Eventos de cargo — referencia completa de todos los eventos de cargo (
charge.declined,charge.canceled, etc.) - Códigos de error HTTP — catálogo completo de errores para manejar fallos del API
- Pagos en modo de pruebas — simular pagos SPEI en modo de pruebas
- Tarjetas de prueba — números de tarjeta y escenarios para modo de pruebas

