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

  1. Tu sistema solicita un checkout — tu backend llama a POST /checkouts con el monto de la transacción, los métodos de pago permitidos y un tiempo de expiración adecuado para pago presencial.
  2. Conekta devuelve la URL de pago — el campo url de la respuesta es la página de pago hosteada. Este es el valor que conviertes en código QR.
  3. 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.
  4. 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.
  5. Tu backend recibe el webhook — una vez que el pago es exitoso, Conekta envía un evento charge.paid a 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ámetroTipoRequeridoDescripción
namestringIdentifica este checkout en tu panel de Conekta. Usa algo significativo como el ID de la terminal o el número de orden.
typestringSiempre "PaymentLink" para este flujo.
recurrentbooleanfalse 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_atintegerTimestamp 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_methodsarrayMétodos disponibles para el cliente. Valores válidos: "card", "bank_transfer" (SPEI), "cash" (OXXO / Conekta Efectivo).
order_template.line_items[].unit_priceintegerMonto en centavos. 8000 = $80.00 MXN.
🚧

El tiempo de expiración debe calcularlo tu servidor

Siempre calcula expires_at en tu backend. Nunca uses un timestamp proveniente del dispositivo cliente — puede ser manipulado. Un patrón seguro es Math.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 qrcode
const 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 recibir order.paid para 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

EventoCuándo se disparaAcción recomendada
order.paidLa orden completa fue liquidada — usa este como señal principal de pagoMarcar la orden como pagada, notificar a la terminal
charge.declinedEl intento de pago fue rechazado (fondos insuficientes, tarjeta bloqueada, etc.)Registrar el rechazo, mostrar mensaje al cliente
charge.canceledEl cargo fue cancelado antes de completarseLimpiar el estado pendiente en tu sistema
📘

¿Por qué order.paid y no charge.paid?

order.paid es 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. Usa charge.paid solo si necesitas detalles específicos del cargo individual.

Payload del webhook — 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 evento order.paid
  • data.object.id — el ID de la orden; correlaciona con el checkout que creaste (guarda el mapeo checkout.id → order.id al crear el checkout)
  • data.object.amount — verifica que coincide con el monto esperado en tu sistema
  • data.object.status — debe ser "paid" antes de marcar la transacción como completada
  • data.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 handler

Conekta reintenta webhooks que no reciben una respuesta 2xx — ver Reintentos de notificación. Guarda el event.id y 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 Required

Esta sección documenta el manejo de webhooks. Confirmar antes de publicar:

  1. Que el nombre exacto del header (DIGEST) coincide con el comportamiento en producción.
  2. Que el endpoint de llave pública (GET /webhooks/keys) existe y está documentado en doc: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 expirados

Cada 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 StatusErrorCausa probableSolución
401UnauthorizedLlave incorrecta o ausenteConfirma que estás usando la llave privada, no la pública
402Payment RequiredProblema con la cuentaRevisa el estado de tu cuenta en Conekta
422Unprocessable EntityCuerpo de petición inválidoVerifica expires_at (debe ser un timestamp futuro), unit_price (entero positivo en centavos) y campos requeridos
500Server ErrorProblema en ConektaReintenta 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.

  1. Crea un checkout con la llave de pruebas — la estructura de respuesta es idéntica a producción.
  2. Abre la url en un navegador (no necesitas escanearla desde un teléfono). Llegarás a la página de pago de Conekta en modo de pruebas.
  3. 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).
  4. Recibe el webhook — usa ngrok para exponer tu servidor local y recibir eventos order.paid reales durante el desarrollo.
🚧

El endpoint es el mismo para pruebas y producción

Conekta usa la misma URL api.conekta.io para ambos ambientes. Lo que cambia es el prefijo de la llave de API: las llaves de pruebas producen livemode: false en todas las respuestas. Nunca escribas una llave directamente en el código — cárgala siempre desde variables de entorno.


Relacionado