Documentación · API v1

Cobra en tu web con validación bancaria al instante

Tu cliente paga por pago móvil o Botón de Pago directo a TU cuenta bancaria — el dinero nunca pasa por nosotros. Lo que hace ArmorPay es confirmar contra el banco, en segundos, que ese pago llegó, alcanza y no se usó antes. Tu pedido se confirma cuando el banco confirma — nunca antes.

Antes de empezar

  1. 1

    Tu comercio tiene que estar ACTIVO

    El alta se hace una sola vez en armorpay.net/registro: subes tus documentos, registras la cuenta bancaria de tu empresa y nosotros la verificamos contra el banco. Mientras tu comercio no esté activo, la API responde 401 a todo — no pierdas tiempo depurando tu código si aún estás en revisión.
  2. 2

    Crea tu llave de API

    En tu panel: API → Crear llave. Se muestra UNA sola vez — guárdala en la configuración de tu servidor. Empieza con ak_live_.
  3. 3

    Registra tu webhook (recomendado)

    En API → Webhooks: pon la URL de tu servidor y guarda el secreto (whsec_...). Es la vía en la que tu tienda se entera de cada pago confirmado sin preguntar. Puedes integrar sin webhook usando GET /intents/{id}, pero el webhook es lo que hace la confirmación instantánea.

¿Cómo pruebo? (no hay modo sandbox)

La API valida contra pagos reales del banco, así que la prueba honesta es un pago real chiquito: crea un intent de 1,00 Bs, paga por pago móvil a tu propia cuenta desde otro banco, y valida la referencia. Es un pago a tu propia empresa: no pierdes nada y pruebas el circuito completo, webhook incluido.

Las 3 vías de integración

De menos a más código. Las tres confirman con las mismas reglas — elige por comodidad, no por seguridad.

VíaCódigo que escribesÚsala si…
Plugin WooCommerceNinguno: instalar y pegar 2 valoresTu tienda es WordPress + WooCommerce.
Checkout alojado1 llamada + 1 redirecciónCualquier carrito propio: nosotros ponemos el formulario de pago.
API completaTu propio formulario + 2-3 llamadasQuieres la experiencia 100% con tu marca, o cobras desde una app.

El flujo completo, de punta a punta

Sea cual sea la vía, por debajo siempre pasan estas cuatro cosas, en este orden:

  1. 1

    Tu servidor crea el intent (el cobro que esperas)

    Con el monto que TU sistema calculó — nunca un monto que declare el navegador del cliente. El intent vence a los 30 minutos.
  2. 2

    Tu cliente paga

    Por pago móvil a tu cuenta (y te da los últimos dígitos de la referencia del comprobante), o por Botón de Pago C2P (genera una clave en su banco y el débito es al instante).
  3. 3

    ArmorPay valida contra el banco

    El pago existe, el monto alcanza y esa referencia no se usó antes — ni en tu web ni en tus cajas físicas. Es el mismo árbitro antifraude para todos tus canales.
  4. 4

    Tu tienda se entera y entrega

    Por el webhook firmado (instantáneo) o consultando el intent. Cuando el status es CONFIRMED, entregas el pedido.

En curl, la integración mínima con checkout alojado son DOS pasos:

# 1. Crear el intent (server-to-server, desde tu backend)
curl -X POST https://armorpay.net/api/v1/intents \
  -H "Authorization: Bearer ak_live_TU_LLAVE" \
  -H "Idempotency-Key: pedido-8812" \
  -H "Content-Type: application/json" \
  -d '{ "externalRef": "8812", "amountVES": "1450.00", "concepto": "Pedido 8812" }'

# → 201 { "intent": { "id": "cmm...", "status": "PENDING", ... } }

# 2. Redirigir al cliente a la página de pago
https://armorpay.net/pay/cmm...

# 3. (automático) Al confirmarse te llega el webhook intent.confirmed
#    — o consultas tú mismo:
curl https://armorpay.net/api/v1/intents/cmm... \
  -H "Authorization: Bearer ak_live_TU_LLAVE"
# → 200 { "intent": { "status": "CONFIRMED", "method": "REFERENCIA", ... } }

Autenticación

Todas las llamadas llevan tu llave en el header Authorization. La llave es secreta y solo de servidor: nunca la pongas en el navegador de tus clientes, en el código fuente visible de tu tienda ni en una app instalable. Si se te filtra, revócala y crea otra desde el panel — al instante.

Base:          https://armorpay.net/api/v1
Autorización:  Authorization: Bearer ak_live_...

Límites: 60 peticiones/min por llave · 15 intentos/5 min por IP
en validación de referencia. Al superarlos: 429 con Retry-After.

# La API es server-to-server: no hay CORS abierto. Si intentas llamarla
# con fetch() desde el navegador, fallará — y así debe ser: proteger tu
# llave es proteger tu dinero.

Cobros (intents)

Un intent es un cobro que esperas recibir. Lo creas server-to-server con el monto que TÚ decides — la validación compara contra ese monto, nunca contra lo que declare el cliente final.

POST/api/v1/intents

GET/api/v1/intents/{id}

POST /api/v1/intents
Authorization: Bearer ak_live_...
Idempotency-Key: pedido-8812        # obligatorio: único por pedido
Content-Type: application/json

{
  "externalRef": "8812",            # el id del pedido en TU sistema
  "amountVES": "1450.00",           # máx. 2 decimales; string o número
  "concepto": "Tienda X pedido 8812"  # opcional, ≤40 tras sanear
}

# ¿Tus precios están en dólares? Manda amountUSD EN VEZ de amountVES:
# congelamos el monto en Bs con la tasa BCV del momento, y la validación
# acepta también USD × tasa vigente (el que paga con la tasa de hoy no falla).
# { "externalRef": "8812", "amountUSD": "25.00" }
# → el intent trae además amountUSD y exchangeRateUsed.

→ 201
{
  "intent": {
    "id": "cmm...",                 # úsalo para validar o redirigir a /pay
    "externalRef": "8812",
    "amountVES": "1450.00",
    "concepto": "Tienda X pedido 8812",
    "method": null,                 # REFERENCIA | C2P al confirmarse
    "status": "PENDING",            # ver ciclo de vida abajo
    "referencia": null,
    "overpaidVES": null,
    "expiresAt": "2026-08-06T21:30:00.000Z",
    "confirmedAt": null,
    "createdAt": "2026-08-06T21:00:00.000Z"
  }
}

# Reintentar con la MISMA Idempotency-Key devuelve el mismo intent (200):
# un timeout de red nunca duplica un cobro. Usa el id de TU pedido como
# key y el reintento sale gratis.

GET /api/v1/intents/{id}
→ 200 { "intent": { ...la misma forma... } }
# Consúltalo al volver el cliente a tu tienda o como respaldo del webhook.
# Es de lectura: consultarlo no cambia nada.

La Idempotency-Key sale del PEDIDO, no de cada carga de la página

Es el error de integración más común que hemos visto en producción: la key se genera con un id nuevo en cada render, así que un F5 del comprador —o un doble clic en «Pagar»— abre un cobro nuevo en vez de recuperar el que ya existía. Los intents huérfanos vencen y ensucian tus reportes; el comprador termina con dos pantallas de pago para el mismo carrito.

✗ Idempotency-Key: armorpay-${crypto.randomUUID()}   // nueva en cada render
✓ Idempotency-Key: pedido-8812                       // el id de TU pedido

Con la key del pedido, recargar devuelve el mismo intent con 200. Y si ese intent ya venció (30 min), ahí sí toca uno nuevo: agrégale un sufijo de intento — pedido-8812-2 — en vez de un id al azar.

Ciclo de vida del intent

PENDING ──(pago validado)──────────→ CONFIRMED   (final: entrega el pedido)
   │
   └──(30 min sin confirmarse)─────→ EXPIRED     (final: crea uno nuevo)

CONFIRMED y EXPIRED son finales: un intent nunca se confirma dos veces ni «revive» después de vencer. Si el cliente quiere pagar un pedido vencido, crea un intent nuevo con otra Idempotency-Key (por ejemplo pedido-8812-2).

Validar una referencia

Tu cliente ya pagó por pago móvil a tu cuenta y te da los últimos dígitos de la referencia de su comprobante (pídele 6 o más). Nosotros confirmamos que el pago existe, alcanza y no se usó antes — el mismo árbitro antifraude que usan las cajas físicas.

Manda lo que te dé el cliente, tal cual: el banco nos notifica la referencia en su forma y cada banco pagador la muestra a su manera. Emparejamos por el final, así que da igual si viene con ceros de más, con espacios o con guiones. Lo único que exigimos son 6 dígitos de verdad.

POST/api/v1/intents/{id}/validate-reference

{
  "referencia": "789123"            # 6 a 20 dígitos, del comprobante
}                                   # sirve completa o solo el final: los
                                    # ceros de adelante y los separadores
                                    # se limpian de nuestro lado

→ 200 (confirmado)
{
  "intent": { ... "status": "CONFIRMED", "method": "REFERENCIA" ... },
  "pago": {
    "referencia": "000000789123",
    "banco": "BDT",                 # banco receptor
    "bancoPagador": "0134 · Banesco",
    "montoVES": "1450.00",
    "overpaidVES": null,            # sobrepago aceptado y registrado
    "fecha": "2026-08-06",
    "hora": "153000"
  }
}

Reglas de monto: se acepta un faltante de hasta max(1 Bs, 0.5%).
Subpago → 422 INSUFFICIENT_AMOUNT (con faltanteVES).
Sobrepago → se confirma y queda en overpaidVES.
Referencia ya cobrada (en caja o por otro intent) → 409 REFERENCE_ALREADY_USED.

El campo de tu formulario: 6 a 20 dígitos, y no recortes

Cada banco pagador le muestra la referencia a su manera: unos dan 9 dígitos, otros 12 con ceros por delante, otros la separan con espacios. Nosotros emparejamos por el final en los dos sentidos, así que da igual cuál de las dos venga más larga — pero solo si tu formulario deja escribir o pegar lo que el banco le mostró al comprador.

✗ <input maxlength="9" pattern="\d{6,9}">   // el comprador no puede pegar la suya
✓ <input inputmode="numeric">              // manda lo pegado tal cual

No le quites espacios ni ceros antes de mandárnosla: eso lo hacemos nosotros. Y no la recortes a los últimos 6 «por si acaso» — mientras más dígitos manda el comprador, menos ambigüedad hay si tiene dos pagos parecidos.

404 PAYMENT_NOT_FOUND no siempre es un error del cliente

La notificación del banco tarda unos segundos en llegarnos después de que tu cliente paga. Si validas en el instante siguiente al pago, puede responder 404. El patrón correcto: reintenta la misma llamada cada 5-10 segundos durante 1-2 minutos antes de decirle al cliente que verifique su pago. Si tras 2 minutos sigue en 404, lo más probable es que el pago haya ido a otra cuenta.

Cobro C2P (Botón de Pago)

Cobro activo: tu cliente genera una clave de pago (OTP) desde la app o banca en línea de su banco, te la da junto a su celular y cédula, y el débito ocurre al instante — sin comprobantes ni referencias que copiar. Requiere que tu comercio tenga C2P habilitado (se tramita con nosotros; en tu panel se ve si ya lo tienes).

POST/api/v1/intents/{id}/c2p

{
  "celular": "04121234567",         # 04 + 9 dígitos (0412, 0422, …)
  "bancoPagador": "0102",           # del catálogo C2P (ver Bancos)
  "cedula": "V12345678",
  "otp": "12345678"                 # clave dinámica que generó tu cliente
}

→ 200 confirmado: { "intent": {...CONFIRMED...}, "cobro": { "referencia", "montoComision", ... } }
→ 422 C2P_REJECTED: rechazo del banco, con "hint" en español y
  "retriable": true — puedes reintentar con una clave nueva
  mientras el intent no venza.
→ 502 BANK_UNAVAILABLE: el banco no respondió. NO asumas rechazo:
  verifica con tu cliente antes de reintentar.

El monto y el concepto salen del intent — el body nunca los lleva.
Pobla el select de bancos con GET /banks?service=c2p (los códigos del
catálogo C2P no siempre coinciden con los del BCV).

Muéstrale al comprador el motivo, no «error al procesar»

El rechazo más común del C2P es la clave dinámica mal escrita o vencida — y se arregla en 10 segundos si el comprador se entera. Nosotros traducimos lo que responde el banco; píntalo tal cual y deja el formulario listo para reintentar con una clave nueva.

→ 422
{
  "code": "C2P_REJECTED",
  "message": "Clave de pago incorrecta",     # titular, ya en español
  "hint": "La clave dinámica está mal escrita, venció o ya se usó.
           Genera una nueva desde tu banco e intenta otra vez.",
  "codres": "C2P0104",                       # el código crudo del banco
  "retriable": true                          # el intent sigue vivo
}

Si el banco responde algo que no conocemos, en hint va su texto crudo: preferimos decirte lo que dijo el banco antes que inventarte un motivo. Con retriable: true el intent sigue vivo hasta que venza — no hace falta crear otro.

La marca del banco en tu checkout

El Banco del Tesoro pide que su logo acompañe el flujo del Botón de Pago. Nuestra página /pay ya lo muestra; si cobras C2P con tu propia interfaz sobre esta API, muéstralo junto al formulario. Sírvelo directo de nuestro dominio: https://armorpay.net/bancos/bt-marca.png (marca a color, para fondos claros) o https://armorpay.net/bancos/bt-blanco.png (marca y nombre en blanco, para fondos oscuros).

Tasa BCV

Fija tus precios con la misma tasa con la que nosotros congelamos y validamos: cero discrepancias entre tu carrito y el cobro.

GET/api/v1/exchange-rate

→ 200
{ "currency": "USD/VES", "rate": "168.4200", "source": "BCV",
  "fetchedAt": "2026-08-06T14:00:00.000Z" }

# Sin tasa utilizable: 503 RATE_UNAVAILABLE — nunca inventamos una.

Cumplimiento en Venezuela

Si tu catálogo muestra precios en divisas, la norma exige que el precio en bolívares esté exhibido y que la conversión sea a tasa oficial BCV, con la moneda y la tasa claramente informadas — nunca una tasa paralela, y nunca precios distintos según el método de pago. Nuestra página de pago ya lo resuelve en el paso de cobro (Bs como monto principal + «Ref. USD … · tasa oficial BCV …»); para tu catálogo, usa este endpoint y muestra ambos. Esto es una guía, no asesoría legal.

Bancos

GET/api/v1/banks

GET /api/v1/banks              # lista BCV — para mostrar el banco pagador
GET /api/v1/banks?service=c2p  # catálogo PROPIO del C2P — para poblar el
                               # select de un cobro C2P (sus códigos no
                               # siempre coinciden con los del BCV)

→ 200 { "service": "...", "banks": [{ "code": "0102", "name": "..." }] }

Checkout alojado (la vía rápida)

Si no quieres construir el formulario: crea el intent y redirige (o abre en iframe) nuestra página de pago. Muestra tu razón social y tu logo, guía al cliente por referencia o C2P según lo que tu comercio tenga habilitado, reintenta sola mientras llega la notificación del banco, y confirma con las mismas reglas de la API.

Redirección:   https://armorpay.net/pay/{intentId}

# No lleva parámetros de retorno: la página no redirige de vuelta sola.
# Pon tú un enlace/botón «volver a la tienda» en tu página de gracias, o
# usa el iframe para quedarte en tu dominio:

En iframe, te avisamos por postMessage:
window.addEventListener("message", (e) => {
  const a = e.data?.armorpay;
  if (a?.event === "confirmed") { /* pagado: a.intentId, a.externalRef */ }
  if (a?.event === "expired")   { /* venció sin pagar */ }
});

# El postMessage es UX (cerrar el modal, mostrar el check): la señal de
# VERDAD para entregar el pedido es el webhook o GET /intents/{id}.
# Un navegador puede fabricar un postMessage; tu servidor no debe creerle.

Plugin WooCommerce

Todo lo de arriba, sin escribir código: el plugin crea el intent al hacer el pedido, manda al cliente a la página de pago, recibe el webhook firmado y marca el pedido como pagado — con un respaldo por consulta cuando el cliente vuelve a la tienda.

  1. 1

    Instala el plugin

    Descarga el .zip y súbelo en Plugins → Añadir nuevo → Subir plugin. Se actualiza solo cuando publicamos versiones nuevas.
  2. 2

    Pega tus 2 valores

    En WooCommerce → Ajustes → Pagos → ArmorPay: tu Llave de API (ak_live_...) y el Secreto del webhook (whsec_...). El título y la descripción que ve tu cliente en el checkout también se editan ahí.
  3. 3

    Registra el webhook apuntando a tu tienda

    En tu panel de ArmorPay (API → Webhooks), la URL es tu tienda más /?wc-api=armorpay — por ejemplo https://mitienda.com/?wc-api=armorpay. El secreto que te dé el panel es el que pegas en el paso 2.
  4. 4

    Prueba con un pedido real de 1 Bs

    Crea un producto de prueba, cómpralo tú mismo pagando 1 Bs a tu cuenta, y verifica que el pedido pase a «Procesando». Luego borra el producto.

Webhooks firmados

Registra tu URL en tu panel (API → Webhooks) y te avisamos a tu servidor cada confirmación o vencimiento — con firma, para que verifiques que fuimos nosotros.

POST a tu URL
x-armorpay-timestamp: 1754516096        # epoch en segundos
x-armorpay-signature: hex(HMAC-SHA256(secreto, timestamp + "." + body))

{ "event": "intent.confirmed",          # o "intent.expired"
  "intent": { ...la misma forma de la API... } }

— Verificación en Node.js —
const crypto = require("node:crypto");
function verificar(secreto, timestamp, firma, bodyCrudo) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const esperada = crypto.createHmac("sha256", secreto)
    .update(timestamp + "." + bodyCrudo).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(firma));
}

— Verificación en PHP —
function verificar($secreto, $timestamp, $firma, $bodyCrudo) {
  if (abs(time() - (int)$timestamp) > 300) return false;
  $esperada = hash_hmac("sha256", $timestamp . "." . $bodyCrudo, $secreto);
  return hash_equals($esperada, $firma);
}

# Usa el body CRUDO (antes de parsear el JSON): re-serializarlo
# cambia bytes y la firma deja de coincidir.

Reglas de la casa

  • Responde 2xx rápido (y procesa después si tu trabajo es lento). Sin 2xx, reintentamos 5 veces con espera creciente: 1 min, 5 min, 30 min, 2 h y 12 h — después la entrega queda marcada fallida y puedes reenviarla a mano desde tu panel.
  • Procesa una sola vez: entre reintentos y reenvíos, el mismo evento puede llegarte dos veces. Usa intent.id + event como clave: si ya lo procesaste, responde 200 y no hagas nada.
  • ¿Rotaste el secreto? Desde el panel puedes rotarlo cuando quieras; actualiza tu servidor en el momento — las entregas siguientes ya van firmadas con el nuevo.

Errores

Toda respuesta de error trae un code estable (programa contra él) y un message en español (muéstralo si te sirve).

HTTPcodeQué hacer
401UNAUTHORIZEDRevisa la llave: inválida, inactiva o el comercio no está activo.
429RATE_LIMITEDEspera lo que diga Retry-After y reintenta.
400IDEMPOTENCY_KEY_REQUIREDManda el header Idempotency-Key al crear intents.
400VALIDATION / INVALID_AMOUNTEl body no cumple el formato; el detalle viene en issues.
404INTENT_NOT_FOUNDEse intent no existe (o no es tuyo).
410INTENT_EXPIREDVenció: crea un intent nuevo.
404PAYMENT_NOT_FOUNDEl pago aún no llegó (o la referencia es de otra cuenta). Reintenta cada 5-10 s durante 1-2 min.
422INSUFFICIENT_AMOUNTSubpago: faltanteVES dice cuánto falta.
409AMBIGUOUS_REFERENCEPide más dígitos de la referencia.
409REFERENCE_ALREADY_USEDEse pago ya se cobró; cobradoPor dice dónde.
422C2P_NOT_ENABLEDEl comercio no tiene C2P habilitado todavía.
422C2P_REJECTEDEl banco rechazó: muestra message y hint tal cual, y deja reintentar con clave nueva.
502BANK_UNAVAILABLEEl banco no respondió: verifica antes de reintentar.
422MERCHANT_NOT_READYEl comercio no tiene cuentas activas.
503RATE_UNAVAILABLESin tasa BCV utilizable: reintenta o cobra en VES.

Checklist antes de salir a producción

  • El monto lo calcula tu servidory va en el intent. Nada del navegador del cliente decide cuánto se cobra.
  • La llave vive solo en tu servidorno en JavaScript del navegador, no en el repositorio público, no en una app.
  • Verificas la firma de cada webhookcon el body crudo, y descartas timestamps de más de 5 minutos.
  • Entregas pedidos solo con CONFIRMEDdel webhook o de GET /intents/{id} — nunca por el postMessage ni porque el cliente 'volvió' a tu tienda.
  • Manejas PAYMENT_NOT_FOUND con reintentosla notificación del banco tarda segundos; no lo trates como fallo definitivo.
  • Tu campo de referencia acepta 6 a 20 dígitossin maxlength de 9 y sin recortar lo que el comprador pega: cada banco se la muestra distinto.
  • La Idempotency-Key sale del pedidono de un id nuevo por render — si no, un F5 abre un cobro nuevo.
  • Le muestras al comprador el motivo del rechazomessage y hint del 422, sobre todo en C2P: casi siempre es solo la clave dinámica.
  • Procesas cada evento una sola vezmismo intent.id + event repetido = responder 200 sin repetir la entrega.
  • Hiciste una compra real de 1 Bsde punta a punta, webhook incluido, antes de anunciar el botón de pago.

Tus ventas en línea, sus estados y cada webhook entregado o fallido los ves en tu panel (Ventas y API → Webhooks) — la misma fuente que usa esta API.

¿Algo no cuadra entre estas docs y la API? Es un bug nuestro — escríbenos a info@armorpay.net.