Saltar al contenido
ApiPay Hub · Docs

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:

MontoQué pasaEstado final
1499000 (…00)Aprueba de inmediatoSUCCEEDED
1499005 (…05)402 card_declined en el confirmFAILED
1499013 (…13)Timeout de pasarela; resuelve por webhook a los 60 sFAILED
1499042 (…42)REQUIRES_ACTION con redirect_url al propio sandboxSUCCEEDED 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íntomaCausa
La firma nunca cuadraexpress.json() registrado antes de la ruta del webhook
La firma nunca cuadra, y express.json() está despuésUn proxy inverso reescribe el cuerpo; comprueba con Content-Length
SignatureVerificationError sólo en producciónReloj del servidor desfasado más de 300 s
La orden se marca pagada dos vecesFalta la deduplicación por evento.id
ConfigurationError al arrancarSe 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 sandbox y el disparo de eventos de prueba.
  • Referencia de SDKs — qué reintenta @apipay/node y qué no.