Saltar al contenido
ApiPay Hub · Docs

Acepta tu primer pago en 10 minutos#

Este quickstart te lleva de cero a un pago aprobado en modo test, sin tocar una pasarela real y sin gastar un peso. Son tres piezas y ningún paso es opcional:

  1. Tu servidor crea un payment intent con la clave secreta sk_test_ y devuelve el client_secret al navegador.
  2. Tu frontend monta el checkout alojado con la clave publicable pk_test_ y ese client_secret.
  3. Tu servidor recibe el webhook payment_intent.succeeded y da la orden por pagada.

Antes de empezar#

Necesitas tres cosas del backoffice (admin.apipay.io), todas en modo test:

QuéDóndePara qué
Clave secreta sk_test_…Claves de API → modo testAutentica tu servidor con el header X-API-Key
Clave publicable pk_test_…Claves de API → modo testVa en el navegador; solo puede leer y confirmar su propio intent
Secreto de firma whsec_test_…Webhooks → crear endpointVerifica que el webhook viene de ApiPay

La pasarela de este quickstart es sandbox: una implementación simulada que solo existe con livemode=false, responde al instante y es gratis. El escenario lo eliges con los dos últimos dígitos del monto; aquí usamos uno que termina en 00, que aprueba.


Paso 0 · Instala el SDK#

pnpm add @apipay/node
# o bien: npm install @apipay/node

Runtimes mínimos: Node.js 22, Python 3.12, PHP 8.3 y Java 17.


Paso 1 · Crea el payment intent en tu servidor#

POST /v1/payment-intents con clave secreta. El monto va siempre como entero en unidades menores de la divisa, junto al código ISO 4217: 1499000 en CLP son $1.499.000, porque el peso chileno tiene exponente 0. Nunca uses coma flotante para dinero.

import { ApiPay } from '@apipay/node';
// Un cliente por proceso: es inmutable y reutiliza el pool de conexiones.
const apipay = new ApiPay({ apiKey: process.env.APIPAY_SECRET_KEY ?? '' });
app.post('/api/checkout/session', async (req, res) => {
  const created = await apipay.paymentIntents.create({
    amount_minor: 1499000, // CLP no tiene decimales: son $1.499.000
    currency: 'CLP',
    gateway_id: 'sandbox', // termina en 00 -> el sandbox aprueba
    description: 'Orden 4831 - Tienda Andina',
    customer_email: 'cliente@example.com',
    return_url: 'https://tienda-andina.cl/checkout/retorno',
    metadata: { order_id: '4831' },
  });
  // Persistir la clave ANTES de responder. El SDK no reintenta POST: si este proceso
  // se cae sin saber si el cobro se creo, reintentar con ESTA clave es lo unico seguro.
  await orders.saveIdempotencyKey('4831', created.idempotencyKey);
  // Al navegador solo viajan el client_secret y el id. La sk_ se queda aqui.
  res.json({ clientSecret: created.client_secret, paymentIntentId: created.id });
});

La respuesta es el recurso completo, en estado CREATED:

{
  "id": "pi_9f2c4a7d1b3e4f60a8c5d2e7",
  "object": "payment_intent",
  "amount_minor": 1499000,
  "currency": "CLP",
  "status": "CREATED",
  "client_secret": "pi_9f2c4a7d1b3e4f60a8c5d2e7_secret_Vt8yQ1kR3nZ",
  "gateway_id": "sandbox",
  "payment_method": null,
  "redirect_url": null,
  "metadata": { "order_id": "4831" },
  "livemode": false,
  "created_at": "2026-08-07T14:30:00Z"
}

Paso 2 · Monta el checkout en el navegador#

@apipay/checkout-js monta un iframe de checkout.apipay.io y habla con él por postMessage. Solo conoce la pk_ y el client_secret.

import { ApiPay } from '@apipay/checkout-js';

// Tu backend crea el intent y devuelve el client_secret (paso 1).
const { clientSecret } = await fetch('/api/checkout/session', { method: 'POST' }).then((r) =>
  r.json(),
);

const checkout = ApiPay.init({ publicKey: 'pk_test_EJEMPLO000000000000000000' }).checkout({
  clientSecret,
  container: '#apipay-checkout', // el elemento ya debe existir en el DOM
  locale: 'es-CL',
  onSuccess: ({ paymentIntentId }) => {
    // Solo UX: navega a "gracias por tu compra". La orden la marca pagada el webhook.
    window.location.assign('/checkout/gracias?intent=' + paymentIntentId);
  },
  onError: ({ code, message }) => {
    // code es estable: payment_failed, intent_expired, session_invalid, embed_origin_denied.
    mostrarAviso(message);
  },
  onCancel: () => {
    volverAlCarrito();
  },
});

// Al desmontar la vista en una SPA, libera el iframe y sus listeners:
// checkout.unmount();

Con el contenedor en tu página:

<div id="apipay-checkout"></div>

Paso 3 · Recibe el webhook de resultado#

Cuando el intent llega a un estado terminal, ApiPay hace POST a tu endpoint con el envelope del evento y el header ApiPay-Signature. Este es el handler mínimo correcto: cuerpo crudo, firma verificada, deduplicación por event.id y 2xx rápido.

import express from 'express';
import { constructEvent, SignatureVerificationError } from '@apipay/node';

const app = express();
app.use('/api', express.json()); // el resto de la app sigue con JSON parseado

// express.raw SOLO en esta ruta: la firma se calcula sobre los bytes originales.
app.post('/webhooks/apipay', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = constructEvent(
      req.body, // Buffer crudo, sin parsear
      req.header('ApiPay-Signature') ?? '',
      process.env.APIPAY_WEBHOOK_SECRET ?? '',
    );
  } catch (error) {
    // Firma invalida o t fuera de tolerancia: 400 y no se procesa nada.
    if (error instanceof SignatureVerificationError) {
      res.status(400).send();
      return;
    }
    throw error;
  }

  // Deduplicar por event.id ANTES de tocar la orden: la entrega es at-least-once.
  if (!eventos.marcarComoVisto(event.id)) {
    res.status(200).send();
    return;
  }

  if (event.type === 'payment_intent.succeeded') {
    const intent = event.data.object;
    cola.encolar('marcar-orden-pagada', { orderId: intent.metadata?.order_id });
  }

  res.status(200).send(); // 2xx rapido; el trabajo pesado va a una cola
});

Envelope que recibes:

{
  "id": "evt_3f8a1c9d2e4b6a07c5d1e9f2",
  "type": "payment_intent.succeeded",
  "created": "2026-08-07T14:30:12.418Z",
  "livemode": false,
  "data": {
    "object": {
      "id": "pi_9f2c4a7d1b3e4f60a8c5d2e7",
      "object": "payment_intent",
      "amount_minor": 1499000,
      "currency": "CLP",
      "status": "SUCCEEDED",
      "gateway_id": "sandbox",
      "redirect_url": null,
      "metadata": { "order_id": "4831" },
      "livemode": false,
      "created_at": "2026-08-07T14:30:00Z"
    }
  }
}

Los detalles que deciden si esto funciona en producción —cuerpo crudo por framework, rotación de secretos, la ventana de 300 s, el calendario de reintentos— están en la guía de webhooks. Léela antes de salir a live: es la parte de la integración donde se concentran los errores.


Qué acaba de pasar#

tu servidor          ApiPay Hub                pasarela            navegador
     |  POST intent  ->  CREATED                   |                   |
     |  <- client_secret                           |                   |
     |                                             |   monta el widget <-
     |                    confirm  ->  cobro   ->  |                   |
     |                    PROCESSING               |                   |
     |                    SUCCEEDED  <-  aprobado  |                   |
     |  <- webhook payment_intent.succeeded        |                   |

El recorrido de estados fue CREATEDPROCESSINGSUCCEEDED.

La guía de payment intents explica la máquina de estados completa y por qué una transición no permitida responde 409 invalid_state_transition.


Siguientes pasos#