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:
- Tu servidor crea un payment intent con la clave secreta
sk_test_y devuelve elclient_secretal navegador. - Tu frontend monta el checkout alojado con la clave publicable
pk_test_y eseclient_secret. - Tu servidor recibe el webhook
payment_intent.succeededy da la orden por pagada.
Antes de empezar#
Necesitas tres cosas del backoffice (admin.apipay.io), todas en modo test:
| Qué | Dónde | Para qué |
|---|---|---|
Clave secreta sk_test_… | Claves de API → modo test | Autentica tu servidor con el header X-API-Key |
Clave publicable pk_test_… | Claves de API → modo test | Va en el navegador; solo puede leer y confirmar su propio intent |
Secreto de firma whsec_test_… | Webhooks → crear endpoint | Verifica 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/nodeRuntimes 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 CREATED → PROCESSING → SUCCEEDED.
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#
- Modo test y sandbox: la tabla de escenarios por los dos últimos dígitos del
monto (
00aprueba,05rechaza,13da timeout,42pide acción). - Confirmación: hosted fields con token frente a flujos de redirección.
- Manejo de errores: los 17 códigos y por qué la lógica se escribe contra
codey nunca contradetail. - Reembolsos: totales, parciales y el 422
amount_exceeds_refundable. - Guías por pasarela: Webpay, MercadoPago.
- Proyectos completos que compilan: Express, Laravel, FastAPI, Spring Boot.