Receta: Express#
Un proyecto completo y ejecutable con Express 5 y @apipay/node: crea el intent, sirve la
página del widget y recibe los webhooks verificando la firma sobre el cuerpo crudo. Siete archivos,
sin nada de más.
mi-tienda/
├── package.json
├── tsconfig.json
├── .env.example
└── src/
├── apipay.ts cliente compartido, uno por proceso
├── ordenes.ts persistencia de juguete (en produccion, tu base de datos)
├── pagina.ts el HTML del checkout, sin un solo campo de tarjeta
└── server.ts las tres rutas
package.json#
{
"name": "mi-tienda",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"dev": "node --env-file=.env --import tsx src/server.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@apipay/node": "^1.0.0",
"express": "^5.1.0"
},
"devDependencies": {
"@types/express": "^5.0.1",
"@types/node": "^22.15.17",
"tsx": "^4.19.0",
"typescript": "5.9.2"
}
}
--env-file es nativo de Node 22: no hace falta dotenv.
tsconfig.json#
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
.env.example#
# Backoffice > API keys. La sk_ JAMAS llega al navegador.
APIPAY_SECRET_KEY=sk_test_EJEMPLO000000000000000000000000
APIPAY_PUBLIC_KEY=pk_test_EJEMPLO000000000000000000000000
# Backoffice > Webhooks. Se muestra UNA sola vez al crear el endpoint.
APIPAY_WEBHOOK_SECRET=whsec_test_EJEMPLO0000000000000000000
# URL publica de esta app: la usan return_url y el endpoint de webhooks.
PUBLIC_BASE_URL=http://localhost:3000
PORT=3000
src/apipay.ts#
import { ApiPay } from '@apipay/node';
function requerido(nombre: string): string {
const valor = process.env[nombre];
if (valor === undefined || valor === '') {
throw new Error(`Falta la variable de entorno ${nombre}`);
}
return valor;
}
/**
* Un cliente por proceso: es inmutable y sin estado mas alla de su configuracion.
* Una clave pk_ se rechazaria aqui mismo, al construir.
*/
export const apipay = new ApiPay({
apiKey: requerido('APIPAY_SECRET_KEY'),
timeout: 30_000,
maxRetries: 2,
});
export const clavePublica = requerido('APIPAY_PUBLIC_KEY');
export const secretoWebhook = requerido('APIPAY_WEBHOOK_SECRET');
export const baseUrlPublica = requerido('PUBLIC_BASE_URL');
src/ordenes.ts#
export interface Orden {
readonly id: string;
readonly amountMinor: number;
readonly currency: string;
// Declarados como `| undefined` y no como opcionales: con
// exactOptionalPropertyTypes, asignar undefined a una propiedad opcional es error.
intentId: string | undefined;
clientSecret: string | undefined;
idempotencyKey: string | undefined;
estado: 'pendiente' | 'pagada' | 'fallida' | 'reembolsada';
}
// Persistencia de juguete. En produccion esto es tu base de datos, y la tabla de
// eventos procesados necesita una restriccion UNIQUE sobre el id del evento.
const ordenes = new Map<string, Orden>();
const eventosProcesados = new Set<string>();
export function crearOrden(id: string, amountMinor: number, currency: string): Orden {
const orden: Orden = {
id,
amountMinor,
currency,
intentId: undefined,
clientSecret: undefined,
idempotencyKey: undefined,
estado: 'pendiente',
};
ordenes.set(id, orden);
return orden;
}
export function buscarOrden(id: string): Orden | undefined {
return ordenes.get(id);
}
export function buscarPorIntent(intentId: string): Orden | undefined {
for (const orden of ordenes.values()) {
if (orden.intentId === intentId) {
return orden;
}
}
return undefined;
}
/**
* Devuelve true la primera vez que se ve el evento y false en los reintentos.
* En produccion: INSERT con UNIQUE(event_id) en la MISMA transaccion que el efecto
* de negocio, porque los reintentos pueden llegar a instancias distintas.
*/
export function registrarEvento(eventId: string): boolean {
if (eventosProcesados.has(eventId)) {
return false;
}
eventosProcesados.add(eventId);
return true;
}
src/pagina.ts#
import { clavePublica } from './apipay';
/**
* Pagina del checkout. NO hay ningun input de tarjeta, ni CVV, ni autocomplete
* de tarjeta: la captura ocurre dentro del iframe alojado de la pasarela.
* Eso es lo que mantiene al comercio en PCI DSS SAQ A.
*/
export function paginaCheckout(orden: {
id: string;
amountMinor: number;
currency: string;
clientSecret: string;
}): string {
const total = new Intl.NumberFormat('es-CL', {
style: 'currency',
currency: orden.currency,
}).format(orden.currency === 'CLP' ? orden.amountMinor : orden.amountMinor / 100);
return `<!doctype html>
<html lang="es-CL">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pagar orden ${orden.id}</title>
</head>
<body>
<h1>Orden ${orden.id}</h1>
<p>Total: ${total}</p>
<p id="apipay-error" role="alert"></p>
<div id="apipay-checkout"></div>
<script src="https://checkout.apipay.io/sdk/v1.0.0/apipay.js" crossorigin="anonymous" defer></script>
<script defer>
addEventListener('DOMContentLoaded', function () {
var errorBox = document.querySelector('#apipay-error');
ApiPay.init({ publicKey: ${JSON.stringify(clavePublica)} }).checkout({
clientSecret: ${JSON.stringify(orden.clientSecret)},
container: '#apipay-checkout',
locale: 'es-CL',
onSuccess: function (r) {
location.assign('/gracias?pi=' + encodeURIComponent(r.paymentIntentId));
},
onError: function (e) {
// Decidir SIEMPRE por code, nunca por el texto del mensaje.
errorBox.textContent =
e.code === 'intent_expired' || e.code === 'session_invalid'
? 'La sesion de pago expiro. Recarga la pagina.'
: 'No pudimos procesar el pago. Prueba con otro medio.';
},
onCancel: function () {
location.assign('/carro');
},
});
});
</script>
</body>
</html>`;
}
src/server.ts#
import express from 'express';
import { Buffer } from 'node:buffer';
import {
ApiConnectionError,
ApiError,
SIGNATURE_HEADER,
SignatureVerificationError,
constructEvent,
type ApiPayEvent,
type PaymentIntent,
type Refund,
} from '@apipay/node';
import { apipay, baseUrlPublica, secretoWebhook } from './apipay';
import {
buscarOrden,
buscarPorIntent,
crearOrden,
registrarEvento,
type Orden,
} from './ordenes';
import { paginaCheckout } from './pagina';
const app = express();
// ---------------------------------------------------------------------------
// 1. WEBHOOK PRIMERO. express.raw en esta ruta y antes de cualquier json().
// ---------------------------------------------------------------------------
app.post(
'/webhooks/apipay',
express.raw({ type: '*/*' }),
(request, response): void => {
const firma = request.header(SIGNATURE_HEADER) ?? '';
const cuerpoCrudo: Buffer = Buffer.isBuffer(request.body)
? request.body
: Buffer.alloc(0);
let evento: ApiPayEvent<PaymentIntent | Refund>;
try {
// Verifica t= y v1= (HMAC-SHA256 sobre "{t}." + bytes, tolerancia 300 s,
// doble v1 durante la rotacion del secreto, comparacion en tiempo constante).
evento = constructEvent(cuerpoCrudo, firma, secretoWebhook);
} catch (error: unknown) {
if (error instanceof SignatureVerificationError) {
console.warn('webhook rechazado:', error.message);
response.status(400).end();
return;
}
// Firma valida pero envelope malformado: tambien 400, y a revisar.
console.error('webhook con envelope invalido:', error);
response.status(400).end();
return;
}
// Deduplicar por evt_ ANTES de tocar la orden: la entrega es at-least-once
// y la plataforma reintenta hasta 5 veces (1m, 5m, 30m, 2h, 12h).
if (!registrarEvento(evento.id)) {
response.status(200).end();
return;
}
switch (evento.type) {
case 'payment_intent.succeeded': {
const intent = evento.data.object as PaymentIntent;
marcar(intent.id, 'pagada');
break;
}
case 'payment_intent.failed':
case 'payment_intent.expired':
case 'payment_intent.canceled': {
const intent = evento.data.object as PaymentIntent;
marcar(intent.id, 'fallida');
break;
}
case 'refund.succeeded': {
const refund = evento.data.object as Refund;
marcar(refund.payment_intent_id, 'reembolsada');
break;
}
default:
// Los tipos nuevos del catalogo son un cambio ADITIVO: ignorar sin fallar.
break;
}
// 2xx rapido. El trabajo pesado (emails, facturacion) va a una cola.
response.status(200).end();
},
);
// A partir de aqui, el resto de la app puede usar JSON parseado con tranquilidad.
app.use(express.json());
function marcar(intentId: string, estado: Orden['estado']): void {
const orden = buscarPorIntent(intentId);
if (orden !== undefined) {
orden.estado = estado;
console.log('orden', orden.id, '->', estado);
}
}
// ---------------------------------------------------------------------------
// 2. Crear el intent y servir la pagina del widget.
// ---------------------------------------------------------------------------
app.get('/checkout/:ordenId', async (request, response) => {
const ordenId = request.params.ordenId;
// El monto termina en 00 => la pasarela sandbox aprueba. Prueba con 05, 13 o 42.
const orden = buscarOrden(ordenId) ?? crearOrden(ordenId, 1_499_000, 'CLP');
if (orden.clientSecret === undefined) {
// Clave estable y derivada de la orden: si hay que reintentar, se reintenta
// con ESTA, nunca con una nueva. El SDK no reintenta POST por diseno.
const idempotencyKey = `orden-${orden.id}-cobro`;
try {
const intent = await apipay.paymentIntents.create(
{
amount_minor: orden.amountMinor,
currency: orden.currency,
gateway_id: 'sandbox',
description: `Orden ${orden.id}`,
customer_email: 'cliente@example.com',
return_url: `${baseUrlPublica}/checkout/${orden.id}`,
metadata: { order_id: orden.id },
},
{ idempotencyKey },
);
orden.intentId = intent.id;
orden.clientSecret = intent.client_secret ?? undefined;
orden.idempotencyKey = intent.idempotencyKey;
} catch (error: unknown) {
if (error instanceof ApiConnectionError) {
// Sin respuesta: NO reintentar a ciegas. Reintentar con la misma clave es
// seguro, y es lo unico seguro.
response.status(503).send('La pasarela no respondio. Reintenta en unos segundos.');
return;
}
if (error instanceof ApiError) {
console.error('apipay', {
code: error.code,
status: error.status,
request_id: error.requestId,
});
response.status(502).send('No pudimos iniciar el pago.');
return;
}
throw error;
}
}
if (orden.clientSecret === undefined) {
response.status(500).send('El intent se creo sin client_secret.');
return;
}
response.type('html').send(
paginaCheckout({
id: orden.id,
amountMinor: orden.amountMinor,
currency: orden.currency,
clientSecret: orden.clientSecret,
}),
);
});
// ---------------------------------------------------------------------------
// 3. Pagina de gracias: el estado real se lee del servidor, no del callback.
// ---------------------------------------------------------------------------
app.get('/gracias', async (request, response) => {
const intentId = typeof request.query.pi === 'string' ? request.query.pi : '';
if (intentId === '') {
response.status(400).send('Falta el identificador del pago.');
return;
}
// Un GET si se reintenta solo ante 429 y 5xx, con backoff y jitter completo.
const intent = await apipay.paymentIntents.retrieve(intentId);
const orden = buscarPorIntent(intent.id);
response.type('html').send(
`<!doctype html><html lang="es-CL"><meta charset="utf-8" />
<h1>Gracias</h1>
<p>Pago <code>${intent.id}</code> en estado <strong>${intent.status}</strong>.</p>
<p>Orden: <strong>${orden?.estado ?? 'desconocida'}</strong> (la confirma el webhook).</p>`,
);
});
app.get('/carro', (_request, response) => {
response.type('html').send('<h1>Carro</h1><p>El pago se cancelo.</p>');
});
const puerto = Number(process.env.PORT ?? 3000);
app.listen(puerto, () => {
console.log(`escuchando en http://localhost:${puerto}`);
console.log(`webhooks en ${baseUrlPublica}/webhooks/apipay`);
});
Arrancar#
cp .env.example .env # y rellena las claves reales de modo test
pnpm install
pnpm dev
# En otra terminal, expon el puerto para que la plataforma alcance tu webhook:
# cloudflared tunnel --url http://localhost:3000
# ...y registra <la-url-publica>/webhooks/apipay en el backoffice.
Abre http://localhost:3000/checkout/4831, pulsa pagar y observa el log del webhook.
Probar los cuatro desenlaces#
La pasarela sandbox decide por los dos últimos dígitos de amount_minor. Cambia el monto en
crearOrden y vuelve a empezar con otra orden:
| Monto | Qué pasa | Estado final |
|---|---|---|
1499000 (…00) | Aprueba de inmediato | SUCCEEDED |
1499005 (…05) | 402 card_declined en el confirm | FAILED |
1499013 (…13) | Timeout de pasarela; resuelve por webhook a los 60 s | FAILED |
1499042 (…42) | REQUIRES_ACTION con redirect_url al propio sandbox | SUCCEEDED o FAILED |
El escenario 13 es el más valioso de los cuatro: es el único que te obliga a comprobar que tu
integración no libera la orden hasta que llega el webhook.
Errores comunes en este stack#
| Síntoma | Causa |
|---|---|
| La firma nunca cuadra | express.json() registrado antes de la ruta del webhook |
La firma nunca cuadra, y express.json() está después | Un proxy inverso reescribe el cuerpo; comprueba con Content-Length |
SignatureVerificationError sólo en producción | Reloj del servidor desfasado más de 300 s |
| La orden se marca pagada dos veces | Falta la deduplicación por evento.id |
ConfigurationError al arrancar | Se pasó la pk_ en APIPAY_SECRET_KEY |
Siguientes pasos#
- Webhooks — verificación paso a paso, rotación de secreto y reintentos.
- Modo test — la tabla completa de
sandboxy el disparo de eventos de prueba. - Referencia de SDKs — qué reintenta
@apipay/nodey qué no.