Todos los métodos. Una integración.
pagolink es el cobro para comercios de Venezuela: Pago Móvil, C2P, Débito Inmediato, Transferencia, Binance Pay, un cobro manual propio (Zelle, Zinli, efectivo…), PayPal y transferencia internacional, con una sola integración en vez de una conexión por método. Los cobros contra el banco se comprueban solos —cada pago, comprobado— y el dinero llega a la cuenta del propio comercio: pagolink no custodia fondos.
Hay dos formas de usarlo: la API / SDK para desarrolladores (cobros en tu app, web o checkout) y el Terminal POS para cobrar en persona. El flujo es siempre el mismo: tu backend crea una sesión, tu frontend abre el Checkout o el Terminal POS con el SDK, y la confirmación llega por callback y por webhook.
window.pagolink.llms.txtDominios
| Servicio | Dominio |
|---|---|
| Sitio | pagolink.co |
| Consola | app.pagolink.co |
| Checkout | pay.pagolink.co |
| Documentación | docs.pagolink.co |
| API | https://api.pagolink.co/v1 |
| API, cuentas | https://api.pagolink.co/accounts/v1 |
Las llamadas de servidor van contra la API: /v1 para cobros y /accounts/v1 para cuentas. Para probar sin mover dinero usa tus claves de prueba con los mismos dominios: ver Modo prueba.
Modo prueba
El alta no crea claves de API. Genera una sk_test_… en Desarrolladores cuando vayas a integrar (el secreto se ve una sola vez, al crearla): con esa clave, cada sesión, enlace y suscripción nace en el entorno de prueba; con sk_live_…, en vivo. La consola abre en Live.
En prueba nunca se toca el banco ni Binance y no se mueve dinero. La sesión ofrece Débito Inmediato, C2P, Pago Móvil y Transferencia sin configurar nada. Binance Pay y el cobro manual se ofrecen solo si los configuraste (Binance con su credencial de prueba; el cobro manual con nombre e instrucciones). La sesión trae testMode: true y muestra datos de cobro de prueba: Banco de prueba (0000), 0414-0000000, J-00000000-0.
| Dato del pagador | Resultado |
|---|---|
1111 | Rechazo del banco simulado. Llega el evento de pago fallido con código TEST_DECLINED. |
2222 | Queda en revisión manual y aparece en Cobros › Por confirmar. |
| cualquier otro | Aprobado. La transacción se completa con una referencia test_…. |
El dato va en la referencia en Pago Móvil, Transferencia y el cobro manual, y en la clave (otp) en C2P y Débito Inmediato. Binance Pay no pide dato y aprueba.
El dato es la referencia en Pago Móvil, Transferencia y el cobro manual, y la clave en C2P y Débito Inmediato. Binance Pay se aprueba siempre. Los webhooks llegan igual que en vivo, con livemode: false, y en prueba no se cobra comisión.
Activar tu cuenta
El alta tiene dos pasos. Tu negocio: tipo (compañía o firma personal), RIF, razón social, dirección fiscal, teléfono de la empresa, correo para pagos y representante legal (nombre y cédula); son los datos que pide el banco y los de la factura. Tu banco, opcional: hoy Banco Plaza (0138). Si ya tienes cuenta a nombre de la empresa das el número (20 dígitos) y el teléfono afiliado a Pago Móvil; si no, pagolink registra la solicitud de apertura con los datos del negocio. No eliges métodos: se piden siempre todos los servicios del banco y, cuando el banco active la cuenta, pagolink enciende Pago Móvil, Transferencia, C2P, Débito Inmediato y Domiciliación. Puedes saltar el banco y conectarlo después en Cuenta de cobro. Binance Pay no es un banco: se configura aparte en Ajustes.
Cuenta de cobro va en cuatro pasos: Datos → Documentos → Revisión del banco → Activa. Registrar la cuenta la deja en borrador («Faltan documentos»); la revisión empieza cuando envías los cinco documentos: RIF, registro mercantil (o de la firma personal), cédula del representante legal (o del titular), el contrato del banco y la carta de autorización firmados. El contrato y la carta se descargan con tus datos para firmarlos, y «Enviar al banco» se habilita con los cinco.
Persona natural. En el paso «Tu negocio» puedes elegir «Persona natural» y registrarte con tu cédula o RIF personal (V, E o P), sin representante legal. Solo se muestran los bancos que la admiten (hoy, Banco Nacional de Crédito) y se piden únicamente Pago Móvil y Transferencia, que pagolink verifica con el banco. C2P, tarjeta, Débito Inmediato, Domiciliación y envíos son para empresas. Los documentos son cuatro: cédula, RIF personal, el contrato del banco y la carta de autorización firmados.
API de cuentas (sesión de la consola): POST /projects/{id}/subrogation con draft: true (y accountOpening: true para una apertura) guarda sin enviar; GET /projects/{id}/subrogations/{banco}/contrato y …/authorization-letter dan los PDF; POST /projects/{id}/subrogations/{banco}/submit la envía y responde 422 NOT_READY con missing si falta algo.
Cobrar en vivo no espera al banco. Con la cuenta creada ya cobras en vivo, en manual: tú confirmas cada pago. Activar tu banco es para que los pagos se confirmen solos y se abran los métodos que ejecuta el banco. Son cuatro pasos, que la consola muestra en la tarjeta «Activa tu banco»: datos del negocio, conectar tu banco (banco, cuenta y qué métodos pides), revisión del banco y confirmación automática. Las claves sk_live_… se emiten siempre.
| Método | Sin banco activo | Con tu banco activo |
|---|---|---|
| Pago Móvil, Transferencia | En manual: lo confirmas tú. Salen de los datos de cuenta de una conexión que no esté rechazada (en trámite, con cuenta y teléfono); sin esos datos no se ofrecen. | Se confirman solos. |
| C2P, Débito Inmediato, Domiciliación | No disponibles. | Disponibles. |
| Binance Pay, Cashea, cobro manual | No dependen del banco: se ofrecen en el checkout solo si el comercio los configuró y encendió en la consola. | |
Los datos que ve el pagador en Pago Móvil y Transferencia son los de tu conexión con el banco, no un formulario aparte.
| Código | HTTP | Cuándo y qué hacer |
|---|---|---|
NO_METHODS_AVAILABLE | 409 | Crear en vivo una sesión o un enlace sin ningún método que ofrecer (la sesión se mira con su monto y su moneda). Activa al menos un método en Métodos. |
BANK_NOT_ACTIVE | 409 | Crear en vivo una suscripción, o el enlace de un plan, sin el banco activo: se cobran por domiciliación. |
DOMICILIATION_NOT_AVAILABLE | 409 | Crear una suscripción con el banco activo pero sin domiciliación disponible: el método está apagado en Métodos o el banco de la cuenta de cobro no tiene bancos de pagadores habilitados. El mensaje dice cuál. |
METHOD_NEEDS_CONNECTION | 409 | Encender C2P, Débito Inmediato o Domiciliación sin una conexión que los incluya, o Pago Móvil y Transferencia sin los datos de cuenta de una conexión. Pídelo desde Cuenta de cobro. |
{ "statusCode": 409, "code": "NO_METHODS_AVAILABLE",
"message": "Activa al menos un método en Métodos." }
GET /v1/account/status (clave secreta o sesión de la consola) responde { liveEnabled: true, bankActive, connections }: liveEnabled queda siempre en true y lo que cambia con tu banco es bankActive.
Equipo y permisos
Cada persona entra a la consola con su propio usuario. El acceso es por organización (el negocio) y alcanza a todos sus proyectos; lo que cada quien puede hacer lo decide su rol. Se administra en la consola, en Ajustes › Equipo y Ajustes › Roles.
| Rol base | Puede |
|---|---|
| Propietario | Todo, incluidos los datos del negocio y transferir la propiedad. Siempre hay al menos uno. |
| Administrador | Todo menos organization.manage. |
| Desarrollador | developers.manage y todas las lecturas. |
| Cobranza | charges.read, charges.create, charges.confirm, payers.read, catalog.read, subscriptions.read, invoices.read. |
| Contador | charges.read, charges.export, invoices.read, invoices.issue, invoices.void, payers.read, catalog.read, subscriptions.read. |
| Solo lectura | Todas las lecturas (*.read). |
| Permiso | Permite |
|---|---|
charges.read | Ver cobros, enlaces de cobro, métricas y liquidaciones. |
charges.create | Crear cobros y enlaces de cobro, editarlos y desactivarlos. |
charges.confirm | Aprobar o rechazar pagos en Cobros › Por confirmar. |
charges.refund | Reembolsar cobros desde la consola. La API pública no tiene ruta de reembolso. |
charges.export | Exportar cobros a CSV o Excel. |
subscriptions.read, subscriptions.manage | Ver suscripciones, crearlas, cobrarlas al momento y cancelarlas. |
invoices.read | Ver facturas y descargar su PDF. |
invoices.issue | Emitir facturas, reenviarlas y hacer notas de crédito. |
invoices.void | Anular facturas. |
catalog.read, catalog.manage | Ver y gestionar productos, precios, categorías, cupones y códigos. |
payers.read | Ver pagadores. |
payment_methods.manage | Encender, apagar y configurar métodos de cobro. |
collection_account.manage | Cuenta de cobro: solicitudes al banco, recaudos y datos fiscales del trámite. |
settings.manage | Marca, apariencia del checkout, facturación fiscal, Cashea, Binance Pay y notificaciones. |
developers.manage | Claves de API, webhooks y URLs de retorno del checkout. |
team.manage | Invitar, cambiar roles, quitar miembros y crear roles a medida. |
organization.manage | Datos del negocio y transferir la propiedad. |
Roles a medida: nombre, descripción y los permisos que marques, por organización. Se editan y se borran; uno en uso no se borra (409 ROLE_IN_USE): reasigna antes a quienes lo tienen. Los roles base no se editan.
Invitar: Ajustes › Equipo › Invitar, con el correo y el rol. Llega un correo con un enlace que vence a los 7 días y sirve una sola vez; reenviar genera un enlace nuevo y anula el anterior, y revocar lo anula sin más. Quien lo abre entra con ese mismo correo o, si no tiene cuenta, la crea ahí (con un código que llega al correo) y acepta. Si ya tenía usuario, solo se suma a la organización. La consola también ofrece copiar el enlace.
Reglas: siempre queda al menos un Propietario; solo un Propietario transfiere la propiedad o asigna, cambia o quita a otro Propietario; nadie cambia su propio rol; nadie puede dar, en un rol o en una invitación, un permiso que no tiene.
Las claves de API (sk_…) no tienen rol: siguen con acceso completo a su proyecto. Quien tiene developers.manage puede emitirlas, así que ese permiso equivale a acceso total por API (ver Seguridad).
Sin el permiso, la API de la consola responde:
{ "statusCode": 403, "code": "PERMISSION_DENIED", "permission": "charges.refund",
"message": "No tienes permiso para reembolsar cobros. Pídeselo a quien administra el equipo de tu organización." }
1. Crear una sesión
Desde tu backend, con tu API key secreta, crea una sesión de cobro. Nunca expongas la API key en el navegador.
// POST https://api.pagolink.co/v1/checkout/sessions (Checkout y Terminal POS)
// x-api-key: sk_live_…
const res = await fetch('https://api.pagolink.co/v1/checkout/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'sk_live_…' },
body: JSON.stringify({ amount: '1250.00', currency: 'VES' }),
});
const { sessionId } = await res.json();
Devuelve el sessionId (txn_…) del cobro, el clientSecret para Elements, el checkoutUrl y expiresAt, la hora en que la sesión deja de aceptar pagos (30 minutos por defecto).
201 { "sessionId": "txn_…", "sessionToken": "…", "clientSecret": "txn_….…", "checkoutUrl": "…", "expiresAt": "…", "environment": "live" }
Tasa de cambio / conversión a VES (opcional)
Si cobras en USD y quieres mostrar el equivalente en bolívares, pasa la tasa al crear la sesión. El Checkout muestra el monto convertido a VES y la línea «Tasa X Bs/USD».
exchangeRate— bolívares por unidad de la moneda base, como cadena (ej."40.5"= 40,5 Bs por USD).amounts— montos explícitos por moneda, ej.{ "VES": 400 }(tiene precedencia sobreexchangeRate).
// cobrar 10 USD mostrando el equivalente en Bs
body: JSON.stringify({ amount: '10.00', currency: 'USD', exchangeRate: '40.5' }),
La tasa la define tu backend con el valor vigente al momento del cobro (no hay feed de tasa incorporado).
Sesión con varios productos
En vez de amount o priceId, manda lineItems: de 1 a 20 precios de pago único del catálogo del entorno de tu clave, todos en la misma moneda. pagolink calcula el total (Σ unitario × cantidad) y, en divisas, el equivalente en Bs a la tasa BCV. Aquí la cantidad es fija; para que el pagador la elija, usa un enlace con productos.
body: JSON.stringify({
lineItems: [ { priceId: 'price_cafe', quantity: 2 }, { priceId: 'price_pan', quantity: 3 } ],
successUrl: 'https://tu-tienda.com/gracias',
}),
La consulta pública de la sesión (GET /v1/checkout/sessions/{id}) trae lineItems para el resumen del pedido (name, description, imageUrl, unitAmount, quantity, amount, sin ids internos), más collect y afterCompletion cuando la sesión viene de un enlace. Los webhooks de pago llevan data.lineItems con lo vendido. Errores: los de la tabla de enlaces con productos. El bloque payment solo viene si la llamada lleva la cabecera x-session-token de la sesión y el cobro está completado; ver Lo que se cobró.
Personalizar este cobro (branding)
Para un cobro concreto puedes cambiar la marca que ve el pagador, sin tocar la configuración de tu proyecto. Cada campo que no mandes sale de la configuración del proyecto.
| Campo | Tipo | Qué es |
|---|---|---|
displayName | texto, de 1 a 80 | Nombre que ve el pagador. |
logoUrl | URL https | Logo del encabezado. |
iconUrl | URL https | Ícono del comercio. |
brandColor | hex #RGB o #RRGGBB | Color de la marca. |
backgroundColor | hex #RGB o #RRGGBB | Color de fondo del checkout. |
font | texto | geist, barlow, inter, dm-sans, manrope, lora, poppins, montserrat, nunito, work-sans, ibm-plex-sans o playfair-display. |
radius | texto | square, soft o round: el borde de tarjetas y botones. |
body: JSON.stringify({
amount: '1250.00', currency: 'VES',
branding: { displayName: 'Evento Sol', brandColor: '#E11D48', backgroundColor: '#FFF7ED', font: 'poppins', radius: 'round' },
}),
Un valor fuera de la tabla responde 400 y nombra el campo. branding no se guarda dentro de tu metadata: no vuelve en el webhook ni se expone en la consulta pública.
La consulta pública de la sesión (GET /v1/checkout/sessions/{id}) devuelve la marca ya resuelta: merchantName, merchantLogo, brandColor, checkoutStyle y los campos nuevos backgroundColor y merchantIcon. Cada uno toma el valor de branding si lo mandaste y el de tu proyecto si no. backgroundColor es el fondo del checkout y merchantIcon el ícono del comercio; los dos son null si no hay ninguno configurado.
Personalizar el checkout de este cobro
Además de la marca, cada sesión puede cambiar el texto del botón, sumar textos propios, pedir hasta tres datos al pagador y pedir que acepte tus términos. Todo viaja en la misma llamada a POST /v1/checkout/sessions y funciona igual en modo prueba que en vivo.
| Campo | Tipo | Qué hace |
|---|---|---|
expiresInMinutes | entero, de 30 a 1440 | Cuánto dura la sesión antes de vencer. Por defecto, 30 minutos. |
customer | objeto, opcional | Datos de tu cliente: name (desde 3 caracteres), taxId (cédula o RIF: letra V, E, J, G o P y de 5 a 10 dígitos), email, phone y address (desde 5 caracteres). Todos son opcionales. El checkout los prellena y el pagador puede cambiarlos; al pagar, lo que el pagador no escribe sale de aquí, y van a la factura digital y al webhook payment.completed en data.payer. Un valor inválido responde 400 con code: "PAYER_FIELD_INVALID" y field: "customer.taxId" (o el campo que falle). La consulta pública los devuelve en customer solo con el token de la sesión. |
submitType | pay, book, donate o subscribe | Cambia el texto del botón principal: «Pagar», «Reservar», «Donar» o «Suscribirme», seguido del monto. Sin él, «Confirmar pago». |
customText.submit | texto, hasta 200 | Se muestra en chico debajo del botón. |
customText.afterSubmit | texto, hasta 500 | Se muestra en la pantalla de pago completado. |
customText.termsOfService | texto, hasta 500 | Se muestra junto a la casilla de términos. |
customFields | lista de 0 a 3 | Campos que el pagador llena antes del botón. Cada uno: key (minúsculas, números y guion bajo, hasta 40), label (hasta 50), type (text, numeric o dropdown), optional (por defecto, obligatorio) y options (solo en dropdown: de 1 a 50 con label y value). |
consentCollection | { "termsOfService": "required" } | Agrega la casilla «Acepto los términos de tu comercio», obligatoria. Va con termsUrl. |
termsUrl | URL https | Enlace de la casilla. Se abre en otra pestaña. Solo con consentCollection. |
body: JSON.stringify({
amount: '25.00', currency: 'USD',
expiresInMinutes: 240,
submitType: 'donate',
customText: { submit: 'Tu donación ayuda a las becas', afterSubmit: 'Gracias por donar.', termsOfService: 'Las donaciones no son reembolsables.' },
customFields: [
{ key: 'campana', label: 'Campaña', type: 'dropdown', options: [ { label: 'Becas', value: 'becas' }, { label: 'Comedor', value: 'comedor' } ] },
{ key: 'referencia', label: 'Número de referencia', type: 'numeric', optional: true },
],
consentCollection: { termsOfService: 'required' },
termsUrl: 'https://tu-tienda.com/terminos',
}),
El texto de customText se muestra como texto: no se interpreta HTML. Un valor fuera de estos límites responde 400 y nombra el campo.
Lo que responde el pagador
Al pagar, el checkout manda customFields (clave → texto) y termsAccepted junto con el método. El servidor revisa lo mismo que el formulario y, si algo falta, responde 400 antes de cobrar:
400concode: "CUSTOM_FIELD_REQUIRED"yfield: "<key>"cuando falta un campo obligatorio.400concode: "CUSTOM_FIELD_INVALID"yfieldcuando el valor no encaja (un número con letras, o una opción que no existe).400concode: "TERMS_NOT_ACCEPTED"cuando la sesión tieneconsentCollectiony el pagador no marcó la casilla.
Lo que contestó queda en el cobro y viaja en el webhook de pago: data.customFields y data.termsAcceptedAt (la hora de la aceptación). Tus opciones (textos, campos y casilla) no viajan en el webhook.
La consulta pública de la sesión (GET /v1/checkout/sessions/{id}) devuelve submitType, customText, customFields y consent ({ termsOfService, termsUrl }) solo cuando los mandaste; si no, esas claves no aparecen.
metadata — tus propias referencias
Pásale a pagolink el número de tu documento en metadata al crear la sesión. El data del evento es la transacción entera, así que tu metadata vuelve dentro de data.metadata del webhook tal cual la mandaste: es lo que permite cerrar la factura correcta sin cruzar por monto y fecha, que es donde se cometen los errores.
POST /checkout/sessions
x-api-key: sk_…
{
"amount": "1250.00",
"currency": "VES",
"metadata": {
"invoiceNumber": "FACT-00012487", // vuelve en data.metadata.invoiceNumber
"clientId": "CLI-00412"
}
}
Es plano y de cadenas, y se valida porque lo escribe un tercero:
| Regla | Límite |
|---|---|
| Claves | hasta 20 |
| Longitud de la clave | 40 caracteres |
| Valor | cadena, hasta 500 caracteres |
| Anidamiento | ninguno — un objeto, un arreglo o un número como valor da 400 |
Estas claves están reservadas porque las escribe pagolink, no el comercio: environment, exchangeRate, amounts, retryOf, retryDepth; las del catálogo (priceId, quantity, baseAmount, baseCurrency, rate, rateValueDate, rateSource); las del enlace y el pago (lineItems, collect, afterCompletion, payer, sessionCustomer); las de descuentos, personalización y términos (discount, invoice, allowPromotionCodes, branding, checkoutOptions, customFields, termsAcceptedAt); las de un ciclo cerrado o cobrado por una suscripción (taxAdded, taxCategory, scheduledChargeId, usage, period, paidOutside); y las que escriben el pago, el banco, la facturación y la suscripción (buyer, fiscal, conversion, binance, lookup, manualReview, linkPayer, subscription, chargeDate, manual, trigger, subscriptionDiscount, cashea, financing, quoted, orderAmount). Mandarlas da 400 nombrando cuál, en vez de descartarlas en silencio — que te dejaría creyendo que las enviaste. Las suscripciones y los cobros programados aceptan metadata con las mismas reglas. En los webhooks de pago, data.metadata trae solo tus claves de texto: las que escribe el servidor no aparecen.
metadata es interno: no viaja al checkout y no se expone en GET /checkout/sessions/{id}. El pagador abre la sesión con un enlace que puede reenviar a cualquiera, así que el número de tu factura y el id de tu cliente no tienen por qué llegar ahí. Esa consulta sólo devuelve exchangeRate y amounts.Catálogo: productos y precios
Como en Stripe: un producto es lo que vendes y un precio, cuánto y cada cuánto. Un precio no se edita: se crea otro que lo reemplaza (cómo). La referencia completa está en Productos, Precios y Tasas BCV.
POST /v1/products
{ "name": "Plan Pro", "kind": "service",
"prices": [{ "unitAmount": "10.00", "currency": "USD", "type": "recurring", "interval": "monthly" }] }
POST /v1/checkout/sessions { "priceId": "price_…", "quantity": 2 }
POST /v1/payment-links { "priceId": "price_…" }
POST /v1/subscriptions { "priceId": "price_…" }
currency es VES, USD o EUR. Un precio en divisas se cobra en bolívares a la tasa oficial del BCV vigente al momento del cobro (en fin de semana o feriado, la que el BCV ya publicó para el próximo día hábil), calculado con 4 decimales y cobrado con 2. Sin tasa publicada el cobro no sale: la sesión responde 503 y la suscripción se reintenta en una hora.
Cada cobro indexado guarda en su metadata priceId, baseAmount, baseCurrency, rate, rateValueDate y rateSource, y llegan en el webhook. Con priceId no se mandan amount, currency, exchangeRate ni amounts. En USD la sesión sale en dólares y en EUR en euros, con el monto en Bs ya calculado.
Incluir el SDK
<!-- CDN -->
<script src="https://cdn.pagolink.co/v1/pagolink.iife.js"></script>
<script>
const pl = pagolink('pk_live_…'); // tu publishable key
</script>
El publishable key (pk_…) es seguro para el frontend. La API key secreta (sk_…) va solo en el servidor.
Modal charge()
Abre el cobro en una ventana modal sobre tu página (Checkout) o el terminal (POS).
pl.charge({
sessionId,
onSuccess: (r) => console.log(r.status), // { sessionId, status }
onCancel: () => console.log('cancelado'),
onError: (e) => console.error(e.message),
});
Embebido mountCharge()
Monta el cobro dentro de un contenedor de tu propia UI (ideal para el POS embebido).
const handle = pl.mountCharge(document.querySelector('#pos-slot'), {
sessionId,
onSuccess: (r) => { /* … */ },
});
// para desmontar
handle.destroy();
Callbacks
| Callback | Payload |
|---|---|
onSuccess | { sessionId, status } |
onCancel | — (el usuario cerró el cobro) |
onError | { code, message } |
Apariencia
Personaliza el cobro con appearance (o el branding de la sesión).
pl.charge({
sessionId,
appearance: {
theme: 'dark', // 'light' | 'dark' | 'auto'
colorPrimary: '#0A0A0A',
colorSuccess: '#0A0A0A',
colorDanger: '#0A0A0A',
borderRadius: '12px',
fontFamily: 'Instrument Sans, sans-serif',
hideBranding: false,
},
});
Los valores son tuyos: el ejemplo va en la escala monocroma de pagolink, pero cada campo acepta el color y la tipografía de tu marca.
Nuevo Elements
Elements pone el formulario de pago dentro de tu página, con los métodos que tu comercio tiene activos. Los datos del pagador (teléfono, cédula, referencia, banco) se escriben en un marco de pagolink: tu página no los ve ni los guarda. La confirmación real llega por el webhook payment.completed; lo que devuelve confirmPayment es el resultado de la pantalla.
Cómo funciona
- Tu servidor crea una sesión con tu clave secreta (
sk_…) y recibe unclientSecret. - Tu página carga el SDK, monta el formulario con ese
clientSecrety el pagador lo completa. - Al confirmar, tu página llama a
confirmPaymenty muestra el resultado. - Tu servidor recibe
payment.completedpor webhook y es quien da el pago por bueno.
1. Crear la sesión en tu servidor
curl https://api.pagolink.co/v1/checkout/sessions \
-H "x-api-key: sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "amount": "25.00", "currency": "USD", "description": "Pedido 1042" }'
# → 201 { "sessionId": "txn_…", "sessionToken": "…", "clientSecret": "txn_….…", "checkoutUrl": "…", "expiresAt": "…", "environment": "live" }
clientSecret es <sessionId>.<sessionToken>. Pásalo a tu página tal cual; el SDK parte por el primer punto. Es la credencial que autoriza a pagar esa sesión: no la guardes en el navegador ni la registres.
2. Cargar el SDK
<script src="https://cdn.pagolink.co/v1/pagolink.iife.js"></script>
3. Montar el formulario
const pl = pagolink('pk_live_…'); // tu clave publicable
const elements = pl.elements({ clientSecret, appearance: { theme: 'pagolink' } });
const payment = elements.create('payment', { layout: 'accordion' }); // o 'tabs'
payment.mount('#pago');
payment.on('change', (e) => {
// e.complete: true cuando el formulario está listo para pagar
// e.empty: true si el pagador no ha escrito nada
// e.value.method: 'pago_movil' | 'c2p' | 'debito_inmediato' | 'transferencia' | 'binance' | null
boton.disabled = !e.complete;
});
payment.on('loaderror', (e) => {
// e.error: el formulario no cargó (sesión vencida, dominio no permitido, etc.)
});
layout admite 'accordion' (por defecto, una opción por método desplegable) y 'tabs' (pestañas, para pantallas anchas).
4. Confirmar el pago
boton.addEventListener('click', async () => {
const { error, payment: result } = await pl.confirmPayment({ elements });
if (error) return mostrarError(error.message); // error.code: ver la tabla de errores
if (result.status === 'completed') return ir('/gracias');
if (result.status === 'processing') return ir('/revisando'); // el banco lo procesa; avisa el webhook
mostrarError('El pago no se pudo completar. Prueba con otro método.'); // result.status === 'failed'
});
result trae status (completed, processing o failed), sessionId y transactionId. Confirma el estado final en tu servidor con el webhook, no solo con esta respuesta.
Con React
import { Elements, PaymentElement, useConfirmPayment } from '@pagolink/react';
function Checkout({ clientSecret }) {
return (
<Elements publishableKey="pk_live_…" clientSecret={clientSecret} appearance={{ theme: 'pagolink' }}>
<PaymentElement layout="accordion" />
<PayButton />
</Elements>
);
}
function PayButton() {
const { confirmPayment } = useConfirmPayment();
return <button onClick={async () => {
const { error, payment } = await confirmPayment(); // mismo resultado que en el SDK
}}>Pagar</button>;
}
El paquete de React usa el mismo formulario que el SDK: misma sesión, mismos métodos y mismo resultado.
Apariencia
Lo que pasas en elements({ appearance }) define cómo se ve el formulario. Sin nada, se usa la apariencia por defecto que guardaste en la consola (Ajustes, pestaña Elements).
| Tema | Qué es |
|---|---|
pagolink | Claro, con fondo blanco, bordes finos y sin sombras. Es el de por defecto. |
night | Oscuro, para páginas con modo nocturno. |
flat | Sin sombras, bordes llenos y radio de 4 px. |
| Variable | Por defecto | Qué cambia |
|---|---|---|
colorPrimary | #0A0A0B | Foco, opción elegida y acciones dentro del Element. |
colorBackground | #FFFFFF | Fondo del componente y de los campos. |
colorText | #0A0A0B | Texto de campos, etiquetas y opciones. |
colorDanger | #B42318 | Mensajes de error y campos con un dato no válido. |
fontFamily | Geist, system-ui | Familia de letra del componente. |
fontSizeBase | 14px | Tamaño base. Los demás textos se escalan a partir de él. |
spacingUnit | 4px | Unidad de espacio. Los márgenes y el relleno son múltiplos. |
borderRadius | 8px | Radio del contenedor, de los campos y de las opciones. |
Las reglas (rules) cambian una parte del componente. Se admiten .Tab (cada opción del acordeón), .Input (campos), .Label (etiquetas) y .Error (mensajes), con un subconjunto de propiedades CSS. No se admiten url(), expression ni @import.
pl.elements({
clientSecret,
appearance: {
theme: 'pagolink',
variables: { colorPrimary: '#FF6B47', borderRadius: '12px' },
rules: { '.Input': { height: '44px' }, '.Label': { 'font-weight': '500' } },
},
});
Dominios permitidos
El Element solo se muestra en los sitios que registraste en la consola (Ajustes, pestaña Elements), por entorno:
- Vivo: solo los orígenes de tu lista. Sin lista, ninguno.
- Prueba: los de tu lista. Sin lista, cualquier
httpsylocalhost.
Cada dominio es un origen: https://tutienda.com, sin rutas, consultas ni comodines (*). localhost solo se acepta en modo prueba. Máximo 20 por entorno.
Seguridad
- Los datos del pagador viven en el marco de pagolink. Tu página no los lee, no los registra y no los envía a tu servidor.
- El
clientSecretautoriza a pagar una sesión. Créalo en tu servidor, entrégalo a la página solo para esa sesión y no lo reutilices para otro cobro. - La confirmación es el webhook
payment.completed, firmado (ver Webhooks). El resultado deconfirmPaymentes para mostrar en pantalla. - Si el pago se rechaza y quieres ofrecer otro método, crea una sesión nueva; la anterior ya no admite pagos.
Errores
| Código | HTTP | Cuándo |
|---|---|---|
INVALID_SESSION | 401 | La sesión no existe o el token no corresponde a ella. |
SESSION_EXPIRED | 410 | La sesión venció. Crea una nueva en tu servidor. |
SESSION_COMPLETED | 409 | La sesión ya se cobró o se está cobrando. Crea otra para otro cobro. |
ELEMENTS_KEY_MISMATCH | 403 | La clave publicable no es del mismo comercio ni del mismo entorno que la sesión. |
ELEMENTS_ORIGIN_NOT_ALLOWED | 403 | El sitio no está en los dominios permitidos de ese entorno. Agrégalo en la consola. |
Los errores de la consola de dominios (ELEMENTS_ORIGIN_INVALID, ELEMENTS_ORIGIN_LIMIT) y de apariencia (ELEMENTS_APPEARANCE_INVALID) salen al guardar en la consola, con el mensaje que indica qué corregir.
Productos
Lo que vendes: un plan, un servicio o un artículo. Cada producto tiene uno o más precios y uno de ellos es el predeterminado, el que se ofrece si no dices otro. Todas las rutas usan tu clave secreta (x-api-key: sk_…) y aceptan el id interno o el público (prod_…, price_…).
El objeto Producto
| Campo | Tipo | Qué es |
|---|---|---|
id | uuid | Id interno. |
publicId | texto | prod_…. El que conviene guardar en tu sistema. |
name | texto | Lo que ve el pagador en el checkout. Hasta 120 caracteres. |
description | texto o null | Hasta 500 caracteres. |
kind | texto | product o service (por defecto). |
imageUrl | texto o null | URL https de la imagen. |
active | booleano | Un producto archivado (false) no se puede cobrar. |
metadata | objeto o null | Tus claves y valores, como texto. |
defaultPriceId | uuid o null | Id interno del precio predeterminado. El primer precio que se crea queda como predeterminado solo. |
prices | Precio[] | Todos sus precios, activos y archivados, del más nuevo al más viejo. |
category | objeto o null | { id: "pcat_…", name }: la categoría del catálogo. |
createdAt, updatedAt | fecha ISO |
Rutas
POST /v1/products # crea el producto, con sus precios si los mandas
GET /v1/products # ?active=true|false &kind=product|service &recurring=true|false &q=texto &categoryId=pcat_…|none &limit=1..100 &offset=0.. → { object: "list", data, total, hasMore }
GET /v1/products/{id} # el producto + usage + deletable
PATCH /v1/products/{id} # name, description, kind, taxCategory, imageUrl, active, metadata, defaultPriceId, categoryId
DELETE /v1/products/{id} # solo si nunca se usó para cobrar
GET /v1/products/export # ?format=csv|xlsx &type=recurring|one_time
q busca en el nombre y la descripción sin distinguir mayúsculas. recurring=true son los que tienen algún precio recurrente (tus planes); recurring=false, los que no.
POST /v1/products
{ "name": "Plan Pro", "kind": "service",
"prices": [{ "unitAmount": "10.00", "currency": "USD", "type": "recurring", "interval": "monthly", "lookupKey": "pro_mensual" }] }
→ 201
{ "id": "7f0c…", "publicId": "prod_3a9…", "name": "Plan Pro", "kind": "service", "active": true,
"defaultPriceId": "c21e…",
"prices": [{ "id": "c21e…", "publicId": "price_91b…", "unitAmount": "10.00", "currency": "USD",
"type": "recurring", "interval": "monthly", "lookupKey": "pro_mensual", "active": true, … }] }
GET /v1/products responde por páginas, igual que las facturas: limit de 1 a 100 (25 por defecto) y offset desde 0, del más nuevo al más viejo. Trae data, total y hasMore; mientras hasMore sea true, pide el siguiente offset. q busca en el nombre, la descripción, el id y las claves de búsqueda de los precios.
GET /v1/products/{id} suma usage —por cada precio, cuántas suscripciones activas o en mora, enlaces activos y cobros lo usan— y deletable, que es true solo si ningún precio se usó nunca:
{ …, "usage": { "c21e…": { "subscriptions": 12, "paymentLinks": 1, "charges": 48 } }, "deletable": false }
Un producto que ya cobró no se borra: se archiva con PATCH {"active": false} y sus cobros viejos siguen apuntando a él. Borrarlo da 409.
| Error | Cuándo |
|---|---|
400 | Un campo inválido, o defaultPriceId que no es un precio activo de ese producto. |
404 | El producto no existe o es de otro negocio. |
409 | DELETE de un producto que ya se usó: «Este producto ya se usó para cobrar: archívalo en vez de eliminarlo.» |
Categorías
Agrupan tus productos para encontrarlos y filtrarlos. Van por entorno, como el catálogo. Asigna una con categoryId al crear o editar el producto (null la quita; una categoría de otro entorno da 400 UNKNOWN_CATEGORY) y filtra la lista con ?categoryId=pcat_…, o none para los que no tienen.
Cada producto tiene su IVA en taxCategory: STANDARD (16 %, por defecto), REDUCED (8 %), LUXURY (31 %) o EXEMPT. Lo usan la factura de cada cobro (cada línea con el IVA de su producto; un cobro sin producto va con el general) y la factura hecha a mano al elegirlo del catálogo. Si el comercio factura, el checkout le enseña al pagador ese mismo IVA. Con «los precios incluyen IVA» apagado en Facturación, el precio es la base y el cobro suma el IVA de cada línea, también en suscripciones y al aplicar un cupón.
GET /v1/product-categories # { data: [{ id: "pcat_…", name, position, productCount, … }] }, por position y nombre
POST /v1/product-categories # { "name": "Bebidas", "position"? } sin position va al final
PATCH /v1/product-categories/{id} # { "name"?, "position"? }
DELETE /v1/product-categories/{id} # 204; 409 CATEGORY_NOT_EMPTY si tiene productos
El nombre no se repite en el mismo entorno, sin distinguir mayúsculas: 409 CATEGORY_NAME_TAKEN.
Precios
Cuánto y cada cuánto se cobra un producto. En Venezuela el precio se fija en divisas (USD o EUR) y el pagador que paga en bolívares lo hace a la tasa BCV del momento del cobro; también se puede fijar en VES. Un precio no cambia: para subir un plan se crea otro y se archiva el viejo (cómo).
El objeto Precio
| Campo | Tipo | Qué es |
|---|---|---|
id, publicId | uuid, texto | Id interno y price_…. Cualquiera de los dos sirve como priceId. |
productId | uuid | Su producto. |
unitAmount | texto | Monto por unidad con 2 decimales, "10.00". Nunca un número: así no se pierde un céntimo. |
currency | texto | USD, EUR o VES. |
type | texto | one_time (pago único) o recurring. |
interval | texto o null | Solo en recurrentes: weekly, biweekly, monthly, quarterly (trimestral), semiannual (semestral) o yearly. Los que cuentan meses se quedan en el último día del mes si el día no existe (31 de enero + 1 mes = 28 o 29 de febrero). |
active | booleano | Un precio archivado no abre cobros nuevos; los que ya salieron no cambian. |
nickname | texto o null | Un nombre interno («Precio de lanzamiento»). El pagador no lo ve. |
lookupKey | texto o null | Tu clave estable para pedir el precio sin guardar su id (ver). Letras, números y _ . : -, hasta 100. Única entre los precios activos del negocio. |
metadata | objeto o null | Tus claves y valores. |
createdAt | fecha ISO |
Rutas
POST /v1/products/{id}/prices # un precio nuevo del producto
GET /v1/prices # ?lookupKeys=a,b &productId= &active=true|false &type=one_time|recurring
GET /v1/prices/{id}
PATCH /v1/prices/{id} # nickname, lookupKey (null la quita), metadata, active
POST /v1/prices/{id}/archive # deja de abrir cobros nuevos
DELETE /v1/prices/{id} # solo si nunca se usó para cobrar
Los precios por uso agregan billingScheme, meter, usageUnitAmount y tiers al objeto: ver Facturación por uso.
Al crear un precio, además de unitAmount, currency, type, interval y nickname, puedes mandar:
| Campo | Qué hace |
|---|---|
lookupKey | Le pone tu clave. Si otro precio activo ya la tiene, 409. |
transferLookupKey | true: si otro precio activo tiene la clave, se la quita y la pasa a este en vez de dar 409. |
replacesPriceId | Precio de este mismo producto que este reemplaza: se archiva en la misma operación. |
setDefault | true: este precio pasa a ser el predeterminado. |
metadata | Tus claves y valores. |
GET /v1/prices devuelve cada precio con su producto resumido: "product": { "id", "publicId", "name" }. Archivar el precio predeterminado deja el producto sin predeterminado hasta que elijas otro.
| Error | Cuándo |
|---|---|
400 | Un recurrente sin interval, un monto de cero (salvo en un precio por uso, donde el cargo base puede ser cero), replacesPriceId de otro producto, o un PATCH que intenta cambiar unitAmount, currency, type o interval: «un precio no cambia: crea uno nuevo con replacesPriceId». |
404 | El precio no existe o es de otro negocio. |
409 | La lookupKey ya la usa otro precio activo, o DELETE de un precio que ya se usó: «Este precio ya se usó para cobrar: archívalo en vez de eliminarlo.» |
Cambiar un precio
Igual que en Stripe: no se edita el monto, se crea un precio nuevo que reemplaza al viejo. Una sola llamada archiva el viejo, deja el nuevo como predeterminado si el viejo lo era y, con transferLookupKey, le pasa la clave. Los cobros y las suscripciones que ya salieron siguen con el precio con que se hicieron; lo nuevo que se cree desde la clave o el predeterminado usa el nuevo.
# Plan Pro sube de 10 a 12 USD al mes
POST /v1/products/prod_3a9…/prices
{ "unitAmount": "12.00", "currency": "USD", "type": "recurring", "interval": "monthly",
"replacesPriceId": "price_91b…", "transferLookupKey": true }
→ 201 { "publicId": "price_d40…", "unitAmount": "12.00", "lookupKey": "pro_mensual", "active": true, … }
price_91b… queda archivado y sin clave; prod_3a9… tiene defaultPriceId del nuevo
priceId.Usar lookupKey en tu integración
Guardar price_… en tu código obliga a cambiarlo cada vez que cambia el precio. Con una clave tuya —pro_mensual, pro_anual— pides el precio vigente por su nombre y lo usas como priceId:
GET /v1/prices?lookupKeys=pro_mensual,pro_anual&active=true
→ [{ "id": "…", "publicId": "price_d40…", "lookupKey": "pro_mensual", "unitAmount": "12.00", "currency": "USD",
"product": { "id": "…", "publicId": "prod_3a9…", "name": "Plan Pro" }, … }, …]
POST /v1/subscriptions { "priceId": "price_d40…", … }
Cuando cambies el precio con transferLookupKey, la misma consulta devuelve el nuevo sin que toques tu integración.
Autorizar la domiciliación: el primer cobro
Cada banco decide a qué cuentas domicilia. La domiciliación de Banco Plaza es interbancaria: acepta cuentas de cualquier banco del cliente, así que GET /v1/sub/{publicId} trae payerBanks: null. Si un banco restringe sus cuentas, payerBanks trae los que acepta, y autorizar con una cuenta de otro banco responde 400 DOMICILIACION_BANK_NOT_ALLOWED con allowedBanks.
El cliente autoriza en /sub/{publicId} y el primer cobro se intenta en ese momento. POST /v1/sub/{publicId}/authorize responde qué pasó con él y cuándo es el siguiente:
{ "status": "active", "publicId": "sub_…", "affiliation": "affiliated",
"firstCharge": { "status": "completed", "transactionId": "…", "amount": "8578.88", "currency": "VES" },
"nextChargeAt": "2026-12-30T16:00:00.000Z" }
firstCharge.status: completed (cobrado), processing (el banco lo procesa; se avisa por webhook), failed con reason (affiliation_pending: el banco aún no aprueba la domiciliación y no cuenta como mora; bank_declined; mandate_incomplete) o deferred (sin tasa BCV del día: se intenta en una hora). Un fallo se reintenta automáticamente. Si el banco exige la aprobación previa del cliente, la respuesta trae affiliation: "pending": ver Aprobación en el banco.
Enlaces de cobro
Un enlace es un cobro que se comparte (WhatsApp, correo, un QR en el mostrador): quien lo abre ve el monto y a quién le paga, y cada vez que alguien paga se crea una sesión. Las rutas usan tu clave secreta (x-api-key: sk_…); con sk_test_… el enlace nace y se lista en el entorno de prueba, digas lo que digas en el cuerpo. El enlace se abre en https://pay.pagolink.co/l/{slug}.
POST /v1/payment-links
{ "amount": "10.00", "currency": "USD", "description": "Pedido #1042",
"expiresAt": "2026-10-01T23:59:00-04:00", "singleUse": true }
→ { "id": "…", "slug": "plink_7aab5a23dc261030c8", "amount": "10", "currency": "USD", "environment": "live", "active": true, … }
POST /v1/payment-links { "priceId": "price_…" } # monto y moneda salen del precio; kind: "payment" | "subscription" (ver Enlace de suscripción)
GET /v1/payment-links # con lo cobrado: paymentStatus, paidCount, lastPaidAt
GET /v1/payment-links/{id} # id interno o slug plink_…
PATCH /v1/payment-links/{id} # activo, límite, después del pago y datos a pedir
DELETE /v1/payment-links/{id} # lo desactiva: deja de abrir cobros; los cobrados no cambian
| Campo | Tipo | Qué es |
|---|---|---|
amount | texto | Monto con hasta 2 decimales, "10.00". Obligatorio sin priceId. |
currency | texto | USD, EUR, USDT o VES. En divisas, quien paga en bolívares lo hace a la tasa BCV del momento en que abre el enlace. Obligatorio sin priceId. |
priceId | texto | Un precio de pago único del catálogo (price_… o id interno). Con él no se mandan amount ni currency. |
lineItems | lista | Varios productos con cantidades: ver Enlaces con productos. |
description | texto | Lo que ve el pagador. Hasta 255. |
expiresAt | fecha ISO | Desde cuándo ya no abre cobros. Tiene que ser futura; sin ella, no vence. |
singleUse | booleano | Un solo pago. Sin él, el enlace sirve para varios pagos. |
payerName, payerDocument, payerEmail, payerPhone | texto | A quién va dirigido (opcional). Se rellenan en el checkout y no salen en la consulta pública del enlace. |
environment | texto | live o test. Con clave lo fija la clave; solo la consola lo elige. |
Errores: 400 con el campo que falla (monto sin moneda, fecha pasada), 401 sin clave, 404 si el enlace no es de tu negocio.
Enlaces con productos
Como los Payment Links de Stripe: el enlace lleva varios productos del catálogo, quien lo abre ve una ficha con foto, nombre y precio de cada uno, elige cantidades dentro del rango que tú fijas y paga el total. Manda lineItems en vez de priceId o amount. El catálogo va por entorno: con sk_test_… solo sirven precios de prueba.
POST /v1/payment-links
{
"lineItems": [
{ "priceId": "price_cafe", "quantity": 1, "adjustableQuantity": { "enabled": true, "minimum": 1, "maximum": 10 } },
{ "priceId": "price_pan", "quantity": 2 }
],
"restrictions": { "completedSessionsLimit": 50 },
"afterCompletion": { "type": "message", "message": "¡Gracias! Te escribimos para la entrega." },
"collect": { "email": "required", "phone": "optional", "address": "none" }
}
→ { "slug": "plink_…", "amount": "7.00", "currency": "USD", "productId": null,
"lineItems": [ { "priceId": "…", "quantity": 1,
"adjustableQuantity": { "enabled": true, "minimum": 1, "maximum": 10 },
"price": { "unitAmount": "4.50", "currency": "USD", "type": "one_time" },
"product": { "id": "…", "name": "Café", "description": "…", "imageUrl": "…" } }, … ],
"restrictions": { "completedSessionsLimit": 50, "completedSessionsCount": 0 },
"afterCompletion": { … }, "collect": { … }, "soldOut": false, … }
| Campo | Tipo | Qué es |
|---|---|---|
lineItems | lista (1–20) | Precios de pago único, del entorno de la clave y todos en la misma moneda. Sin priceId, amount ni currency. Un mismo precio no se repite: la cantidad va en su línea. |
lineItems[].quantity | entero | Cantidad inicial. Por defecto 1. |
lineItems[].adjustableQuantity | objeto | { enabled, minimum, maximum }: el pagador cambia la cantidad dentro del rango (por defecto de 1 a 99). Sin él, la cantidad es fija. |
restrictions.completedSessionsLimit | entero | Cuántos pagos completados admite. Al llegar, el enlace se desactiva solo y quien lo abre ve «agotado» (soldOut). |
afterCompletion | objeto | Qué ve el pagador al terminar: { "type": "message", "message": "…" } (hasta 500) o { "type": "redirect", "url": "https://…" } (solo https). |
issueInvoice | booleano | Por defecto true. Con false, los cobros de este enlace no generan factura automática aunque la facturación esté encendida en Ajustes; con ella apagada no hay nada que emitir y el campo no cambia nada. El servidor lo lleva a la sesión como metadata.invoice = "skip", una clave reservada. Se puede cambiar con PATCH. |
collect | objeto | Datos que se le piden al pagador: email, phone, address, name (nombre o razón social) y taxId (cédula o RIF), cada uno none, optional o required (true vale como required). |
Con lineItems el enlace es reutilizable salvo que mandes singleUse: true. amount es el total con las cantidades iniciales (Σ unitario × cantidad). El priceId suelto sigue funcionando y se lee como una línea de cantidad 1.
Cambiar un enlace
PATCH /v1/payment-links/{id}
{ "active": false, "restrictions": { "completedSessionsLimit": 100 }, "afterCompletion": null, "collect": { "phone": "required" } }
GET /v1/payment-links?productId=prod_… # los enlaces de un producto
Se cambia active, restrictions, afterCompletion, collect e issueInvoice (null quita los que lo admiten). Los productos y el monto no cambian, como en Stripe: para vender otra cosa, crea otro enlace. El límite no puede quedar por debajo de los pagos ya completados.
Lo que ve quien abre el enlace
La consulta pública (GET /links/{slug}, sin clave) trae la ficha de productos, los datos a pedir y si se agotó, además del equivalente en Bs a la tasa BCV del momento (exchangeRate, rateSource, rateValueDate, amounts), con los mismos nombres que la sesión:
{ "amount": "7.00", "currency": "USD", "soldOut": false,
"lineItems": [ { "priceId": "…", "name": "Café", "description": "…", "imageUrl": "…",
"unitAmount": "4.50", "currency": "USD", "quantity": 1,
"adjustableQuantity": { "enabled": true, "minimum": 1, "maximum": 10 } }, … ],
"collect": { "email": "required", "phone": "optional", "address": "none" },
"exchangeRate": "200.00000000", "rateSource": "bcv", "rateValueDate": "2026-09-29", "amounts": { "VES": "1400.00" } }
POST /links/{slug}/session
{ "quantities": [ { "priceId": "…", "quantity": 3 } ] } # las que no mandes quedan con la del enlace
El total se calcula en el servidor, Σ unitario × cantidad a 4 decimales y entregado con los de la moneda; en divisas se cotiza una sola vez, sobre el total, a la tasa BCV del momento en que se abre la sesión. La sesión guarda qué se vendió en metadata.lineItems (priceId, productId, name, unitAmount, quantity, amount): aunque el catálogo cambie, el cobro se explica solo.
Datos a pedir al pagador
Si el enlace tiene collect, el pago lleva lo que se pidió en payer, junto a provider y buyerInput. Un campo required vacío o mal escrito responde 400 con el campo, y el cobro no se intenta. Lo que se dio queda en la transacción, en metadata.payer; lo que no se pidió no se guarda aunque venga.
POST /v1/checkout/sessions/{id}/pay
{ "provider": "pago_movil", "buyerInput": { … },
"payer": { "email": "ana@correo.com", "phone": "04141234567", "address": "Av. Principal, Caracas" } }
→ 400 { "code": "PAYER_FIELD_REQUIRED", "field": "email", "message": "Falta el correo del pagador" }
payer.name es el nombre o la razón social (3 caracteres o más) y payer.taxId la cédula o el RIF: letra V, E, J, G o P y de 5 a 10 dígitos; se aceptan guiones, puntos y espacios, y se guarda limpio (J502397027).
Factura digital de cada cobro
El comercio la activa en la consola, en Ajustes › Facturación, con su RIF, razón social y domicilio fiscal. Cada cobro completado —también el que confirma a mano— emite su factura con la imprenta digital. Los cobros de prueba no se facturan. Dos opciones deciden cómo sale:
| Opción | Por defecto | Qué hace |
|---|---|---|
pricesIncludeTax | sí | El precio ya trae el IVA: la factura saca la base de lo cobrado y cierra en lo que pagó el cliente. Sin ella, el IVA va encima del precio. |
collectBuyer | sí | Todo cobro del comercio pide name y taxId obligatorios, address obligatorio en factura digital y email opcional, sumados a lo que pida el enlace. Sin esos datos la factura digital no sale. |
La factura va en la moneda del cobro, con la tasa con que se cobró, y declara el pago en la moneda en que entró (payment). Con productos, una línea por producto; con cupón, el descuento va en las líneas con su concepto. El resultado queda en metadata.fiscal:
{ "fiscal": { "documentId": "…", "number": "00000043", "status": "ISSUED", "issuanceMode": "DIGITAL", "roundingGap": "0.01" } }
# roundingGap solo aparece si ningún monto neto cerraba exacto al céntimo (±0,01)
Si no se pudo emitir, la transacción tiene el evento fiscal_document_failed con el motivo en payload.reason, y el cobro sigue completado.
Imprenta digital del comercio
La factura digital sale por la imprenta autorizada (The Factory HKA) con las credenciales del comercio: sin ellas, la factura se genera y queda sin transmitir. Se cargan desde la consola, en Ajustes › Facturación, o con estas rutas (sesión de la consola). La clave viaja una sola vez al servicio fiscal, que la guarda cifrada; ninguna respuesta la devuelve.
GET /v1/fiscal-config/issuer
→ { "ready": true, "provider": "TFHKA", "hasOwnCredentials": true, "canEmit": true, "request": null }
PUT /v1/fiscal-config/issuer/credentials
{ "username": "…", "password": "…", "series": "A", "branchCode": "01" } # series y branchCode, si la imprenta los dio
→ el mismo estado de arriba
POST /v1/fiscal-config/issuer/activation-request
{ "name": "…", "email": "…", "phone": "…" } # pide el alta ante la imprenta
→ { "status": "PENDING", "requestedAt": "…" }
ready: false significa que el comercio todavía no guardó su RIF, razón social y domicilio fiscal: las credenciales van a nombre de ese contribuyente. Guardarlas lo da de alta en el servicio fiscal si hacía falta. Sin esos datos, la ruta responde 400.
Enlace permanente de un producto
El enlace que va en tu biografía, en un QR o en una publicación y no cambia. Idempotente: sin priceId, la primera llamada lo crea con el precio predeterminado del producto y las siguientes devuelven el mismo enlace, también con 201, mientras esté activo. Con un priceId distinto al del enlace, el anterior se desactiva y se crea otro (la URL cambia). Si lo desactivas, la próxima llamada crea uno nuevo. Un precio de pago único da cantidad ajustable de 1 a 10; uno recurrente da un enlace de suscripción.
POST /v1/products/{id}/payment-link # id interno o prod_…; el entorno sale de la clave
{ "priceId": "price_…" } # opcional: el precio que cobra el enlace
→ 201 { "slug": "plink_…", "productId": "…", "mode": "payment" | "subscription", … } (el mismo en cada llamada, también con 201)
→ 409 { "code": "NO_DEFAULT_PRICE" } sin priceId y sin precio predeterminado activo
→ 400 { "code": "PRICE_NOT_IN_PRODUCT" } priceId no es un precio activo del producto
Enlace de suscripción
Un enlace tiene un tipo, kind: payment (un pago) o subscription. Uno de suscripción cobra un plan y se adapta al banco de quien lo abre. Se crea con un precio recurrente del catálogo (priceId) o con un plan en línea (plan), y ya no exige que el comercio tenga la domiciliación: basta con poder cobrar en bolívares de alguna forma (domiciliación, Débito Inmediato, C2P o los datos de su cuenta para Pago Móvil). Sin ninguna, 409 SUBSCRIPTIONS_NOT_AVAILABLE. Un enlace con un precio recurrente y sin kind también es de suscripción. La respuesta trae kind y, por compatibilidad, mode con el mismo valor.
POST /v1/payment-links
{ "kind": "subscription", "priceId": "price_…", "plan": { "totalCycles": 6, "trialDays": 7 } } # plan: solo las condiciones
{ "kind": "subscription", "plan": { "name": "Plan Pro", "amount": "15.00", "currency": "USD", "interval": "monthly" } } # crea el producto y su precio
→ 201 { "slug": "plink_…", "kind": "subscription", "mode": "subscription", "recurring": { "interval", "unitAmount", "currency" }, "plan": { "totalCycles", "trialDays", "startDate" }, … }
→ 400 SUBSCRIPTION_PLAN_REQUIRED sin priceId ni plan completo, o con monto suelto o productos
→ 400 PRICE_NOT_RECURRING el precio es de pago único
→ 400 TRIAL_AND_START_DATE días de prueba y fecha de inicio a la vez
→ 400 START_DATE_IN_PAST startDate no es futura
→ 400 KIND_MISMATCH kind payment con un precio recurrente
→ 409 SUBSCRIPTIONS_NOT_AVAILABLE el comercio no tiene cómo cobrar el plan
plan.interval es weekly, biweekly, monthly, quarterly, semiannual o yearly; plan.currency, USD (por defecto), EUR o VES; totalCycles (1 a 120) hace un plan en cuotas; trialDays (1 a 365) o startDate (ISO 8601, futura) retrasan el primer cobro.
Cómo se adapta al banco del pagador
Quien abre el enlace ve el plan y elige su banco. La vista pública trae todos los bancos del país con domiciliation: true si el banco del pagador está en la lista de algún banco activo del comercio con domiciliación encendida. immediateDebit dice si el comercio cobra con Débito Inmediato.
- Su banco domicilia. Sigue el flujo del mandato: datos, cuenta de 20 dígitos, aprobar en su banco y la sonda de 1 Bs.
- Su banco no domicilia. Paga la primera cuota ya con Débito Inmediato (cédula, teléfono y la clave que le manda su banco) y el plan queda cobrándose por enlace: en cada fecha le llega el enlace de pago con el Débito Inmediato prellenado y los demás métodos de respaldo.
- Con días de prueba o inicio futuro no se cobra al elegir: se guarda la fecha y el primer enlace llega ese día.
GET /links/{slug} # público: + kind, mode, plan { name, description, imageUrl, amount, currency, interval, totalCycles, trialDays, startDate }, subscriptionsAvailable, banks [{ code, name, domiciliation }], immediateDebit
POST /links/{slug}/subscribe # público, 5 por minuto por IP: crea la suscripción pendiente
→ 201 { "subscriptionId": "sub_…", "subUrl": "https://pay.pagolink.co/sub/sub_…" }
→ 409 { "code": "SUBSCRIPTIONS_NOT_AVAILABLE" } el comercio no cobra suscripciones
→ 400 { "code": "LINK_IS_NOT_SUBSCRIPTION" } el enlace es de un pago
→ 410 el enlace venció o está desactivado
GET /sub/{id} # público: la misma lista banks, immediateDebit, y firstChargeAt, payerBank
POST /sub/{id}/choose-bank # público, 6 por minuto por suscripción
{ "bankCode": "0105", "payerEmail"?, "payerName"?, "payerDocument"?, "payerPhone"? }
→ 201 { "flow": "domiciliation", "bankCode": "0134", "bankName": "Banesco" } sigue el mandato
→ 201 { "flow": "payment_link", "bankCode", "bankName", "paymentUrl": "https://pay…/s/…#t=…", "firstChargeAt": null, "message" } paga la primera cuota
→ 201 { "flow": "payment_link", …, "paymentUrl": null, "firstChargeAt": "2026-11-07T…" } el primer enlace llega ese día
→ 400 INVALID_BANK, 409 CANCELED, 409 ALREADY_PAYMENT_LINK, 409 MANDATE_AUTHORIZED
La sesión de paymentUrl (y la de cada fecha siguiente) trae preferredMethod: "immediate_debit" y payerPrefill con correo, nombre, cédula, teléfono y banco, que el checkout usa para abrir en Débito Inmediato con el formulario lleno. POST /links/{slug}/session en un enlace de suscripción da 400 LINK_IS_SUBSCRIPTION.
Desde la consola el entorno va en ?environment= o en el cuerpo, como en el catálogo; con clave manda la clave.
Errores
| Código | HTTP | Cuándo | |
|---|---|---|---|
ITEMS_MIXED_CURRENCY | 400 | Los precios de las líneas están en monedas distintas. | |
PRICE_NOT_ONE_TIME | 400 | Una línea es un precio recurrente. | |
PRICE_ENVIRONMENT_MISMATCH | 400 | Un precio de prueba en un cobro real, o al revés. | |
DUPLICATE_PRICE | 400 | El mismo precio en dos líneas. | |
LINE_ITEMS_COUNT | 400 | Menos de 1 o más de 20 líneas. | |
QUANTITY_OUT_OF_RANGE | 400 | Una cantidad fuera del rango de su línea, o cambiar la de una línea fija. | |
UNKNOWN_LINE_ITEM | 400 | Una cantidad para un precio que no está en el enlace. | |
PAYER_FIELD_REQUIRED, PAYER_FIELD_INVALID | 400 | Falta un dato que el enlace pide, o no tiene un formato válido. field dice cuál. | |
LINK_SOLD_OUT | 409 | El enlace llegó a su límite de pagos. | |
SUBSCRIPTION_PLAN_REQUIRED | 400 | no | Un enlace de suscripción lleva un precio recurrente (priceId) o un plan completo (plan.name, plan.amount, plan.interval), sin monto suelto ni productos. |
PRICE_NOT_RECURRING | 400 | no | Una suscripción necesita un precio recurrente, no uno de pago único. |
TRIAL_AND_START_DATE | 400 | no | El plan lleva días de prueba o fecha de inicio, no los dos. |
START_DATE_IN_PAST | 400 | no | La fecha de inicio del plan tiene que ser futura. |
KIND_MISMATCH | 400 | no | Un precio recurrente es de una suscripción: crea el enlace con kind: "subscription". |
INVALID_BANK | 400 | no | El código no es de un banco de la lista de bancos del país. |
NO_DEFAULT_PRICE | 409 | El producto no tiene precio predeterminado activo y no se mandó priceId. | |
PRICE_NOT_IN_PRODUCT | 400 | El priceId del enlace permanente no es un precio activo del producto. |
Cupones y códigos promocionales
Como en Stripe: el cupón dice cuánto se descuenta y el código promocional es lo que escribe el pagador. Van por entorno, igual que el catálogo: con sk_test_… solo existen los de prueba. El descuento se calcula en la moneda del precio y lo que se cobra en Bs es el total ya descontado, a la tasa BCV del momento.
POST /v1/coupons
{ "name": "Lanzamiento", "percentOff": "15" }
# o monto fijo: { "amountOff": "2.00", "currency": "USD" }
→ { "id": "cpn_…", "object": "coupon", "percentOff": "15.00", "amountOff": null, "duration": "once",
"appliesTo": null, "maxRedemptions": null, "timesRedeemed": 0, "valid": true, "environment": "test" }
POST /v1/promotion-codes
{ "coupon": "cpn_…", "code": "CAFE15", "maxRedemptions": 100,
"restrictions": { "minimumAmount": "10.00", "minimumAmountCurrency": "USD" } }
→ { "id": "promo_…", "object": "promotion_code", "code": "CAFE15", "active": true, "coupon": { … } }
| Campo | Tipo | Qué es |
|---|---|---|
percentOff | decimal | De 0,01 a 100. O amountOff con currency (VES, USD o EUR), uno de los dos. Un monto fijo solo se aplica a compras en su moneda. |
duration | texto | Para suscripciones: once (hasta el primer cobro que entre), repeating con durationInMonths, o forever. En un cobro suelto da igual. |
appliesTo.products | lista | Productos (prod_…) a los que aplica: el descuento va solo sobre esas líneas. Sin ella, sobre toda la compra. |
redeemBy, maxRedemptions | fecha, entero | Hasta cuándo y cuántas veces se puede usar. valid dice si hoy se puede. |
code | texto | Letras, números, _ y -, de 3 a 32; se guarda en mayúsculas y el pagador lo escribe como quiera. Sin código se genera uno. Dos códigos activos iguales dan 409 PROMOTION_CODE_TAKEN. |
expiresAt, maxRedemptions, restrictions | — | Reglas del código encima de las del cupón: vencimiento, usos y compra mínima (con su moneda). |
Un uso se cuenta cuando el cobro se completa (o cuando el pagador autoriza la suscripción), no al abrir la sesión: un intento abandonado o rechazado no gasta la promoción. El cobro no puede quedar en cero. Del cupón solo cambian name y metadata (PATCH); DELETE /v1/coupons/{id} lo desactiva junto con sus códigos, y los cobros que ya lo usaron lo siguen nombrando. De un código cambia active. Listas: GET /v1/coupons y GET /v1/promotion-codes?coupon=cpn_…&code=…&active=true.
Aplicarlo
POST /v1/checkout/sessions
{ "lineItems": [ … ], "discounts": [ { "coupon": "cpn_…" } ] } # o { "promotionCode": "promo_…" | "CAFE15" }
POST /v1/subscriptions
{ "priceId": "price_…", "discounts": [ { "coupon": "cpn_…" } ] }
POST /v1/payment-links
{ "lineItems": [ … ], "allowPromotionCodes": true } # el pagador ve «¿Tienes un código?»
Un descuento por cobro. Funciona con lineItems, priceId o monto libre; no con exchangeRate ni amounts propios (DISCOUNT_NOT_SUPPORTED), porque no sabríamos qué bolívares cobrar. La sesión guarda lo aplicado en metadata.discount; la consulta pública trae discount (code, name, percentOff, amountOff, currency, subtotal) para el resumen, y el webhook lo trae en data.discount con coupon y promotionCode. En una suscripción, discount dice hasta cuándo descuenta (endsAt) y si el de una vez ya se usó.
POST /links/{slug}/promotion-code # sin clave: lo que ve el pagador antes de pagar
{ "code": "cafe15", "quantities": [ { "priceId": "…", "quantity": 3 } ] }
→ { "code": "CAFE15", "percentOff": "15.00", "amountOff": "1.22", "subtotal": "8.10", "total": "6.88", "currency": "USD",
"exchangeRate": "…", "rateSource": "bcv", "amounts": { "VES": "…" } }
POST /links/{slug}/session
{ "quantities": [ … ], "promotionCode": "CAFE15" }
En el checkout (/s/). Como allow_promotion_codes de Stripe: crea la sesión con "allowPromotionCodes": true y el pagador ve el campo en el checkout. El servidor recalcula el monto, los bolívares (a la tasa guardada en la sesión) y la comisión desde el subtotal original, solo mientras la sesión esté pending y nadie haya intentado pagar.
POST /v1/checkout/sessions
{ "amount": "8.10", "currency": "USD", "allowPromotionCodes": true }
POST /v1/checkout/sessions/{id}/promotion-code # cabecera x-session-token; 10 por minuto por sesión
{ "code": "CAFE15" } # { "code": null } lo quita
→ { "sessionId": "…", "amount": "6.88", "currency": "USD", "amounts": { "VES": "…" }, "discount": { … } }
→ 409 { "code": "SESSION_ALREADY_ATTEMPTED" } ya hubo un intento de pago: el monto no cambia
Errores
| Código | HTTP | Cuándo |
|---|---|---|
PROMOTION_CODE_INVALID, COUPON_NOT_FOUND | 400 | El código no existe en este entorno; el cupón no existe. |
COUPON_EXPIRED, PROMOTION_CODE_EXPIRED | 400 | Pasó redeemBy o expiresAt. |
COUPON_MAX_REDEMPTIONS, PROMOTION_CODE_MAX_REDEMPTIONS | 400 / 409 | Se usó todas las veces permitidas: 400 al aplicarlo, 409 si se agotó mientras se pagaba. |
COUPON_INACTIVE, PROMOTION_CODE_INACTIVE | 400 | Desactivado. |
MINIMUM_AMOUNT_NOT_MET | 400 | La compra no llega al mínimo del código, o está en otra moneda. |
COUPON_NOT_APPLICABLE | 400 | Ningún producto de la compra está en appliesTo. |
COUPON_CURRENCY_MISMATCH | 400 | Monto fijo en una moneda distinta a la de la compra. |
DISCOUNT_COVERS_TOTAL | 400 | El descuento deja el cobro en cero. |
PROMOTION_CODES_NOT_ALLOWED | 400 | El enlace o la sesión no acepta códigos. |
SESSION_ALREADY_ATTEMPTED | 409 | Ya hubo un intento de pago en la sesión: el monto no cambia. |
DISCOUNTS_COUNT, DISCOUNT_NOT_SUPPORTED | 400 | Más de un descuento, o un cobro con tasa o montos propios. |
Binance Pay
Cobras con tu cuenta de Binance Pay: el dinero entra a tu Binance, pagolink no lo recibe ni lo retiene. No depende de tu banco. Hace falta tu API key y tu API secret de Binance Pay (Merchant), por entorno. En modo prueba el checkout simula Binance Pay.
Cómo se configura (consola › Ajustes › Binance Pay):
- Crea tu cuenta de comercio en Binance Pay Merchant. Es distinta a la personal: Binance verifica tu empresa.
- Genera una clave de API en la sección para desarrolladores del portal. El API secret solo se ve una vez.
- Pégalas en Ajustes › Binance Pay. pagolink las comprueba con Binance y las guarda cifradas (no se vuelven a mostrar, solo su huella); no hay webhooks que configurar.
- Enciende Binance Pay en Métodos.
Estas rutas son solo desde la consola: piden la sesión de la consola, no la clave secreta. Con sk_… responden 401.
GET /v1/binance-config?environment=live # estado, sin la credencial
PUT /v1/binance-config/credentials { "apiKey": "…", "apiSecret": "…", "environment": "live" }
DELETE /v1/binance-config/credentials?environment=live # la quita; en vivo también apaga el método
| Código | HTTP | Qué pasó |
|---|---|---|
BINANCE_CREDENTIALS_INVALID | 400 | Binance no aceptó la credencial (binanceCode trae su código). |
BINANCE_CREDENTIALS_MALFORMED | 400 | No parece una credencial: incompleta o con espacios. |
BINANCE_UNAVAILABLE | 503 | Binance no respondió; intenta de nuevo. |
BINANCE_NEEDS_CREDENTIALS | 409 / 400 | Encender el método sin ninguna credencial, o pagar con él en vivo sin la credencial de vivo. |
Si la respuesta trae certificate: false, Binance no le da a tu cuenta el certificado de avisos: los cobros se confirman consultando la orden.
Suscripciones con Binance
Una suscripción en dólares se puede cobrar sola en USDT desde el Binance del cliente, igual que la domiciliación cobra en bolívares desde su banco. El cliente autoriza una vez, en la app de Binance, un contrato con un tope por cobro y la frecuencia del plan; después cada cuota sale sin que vuelva a entrar. El dinero entra a tu cuenta de Binance Pay.
Requisitos. Binance habilita Direct Debit solo a cuentas de empresa y lo aprueba aparte: pídelo en el portal de Binance Pay, en Debit Management. Cuando te lo aprueben, enciéndelo en consola › Ajustes › Binance Pay › Cobro automático de suscripciones, con el tope por cobro que te dio Binance (sin tope, el de Binance por defecto: 50 USDT).
Qué planes lo ofrecen. En dólares (se cobra en USDT, 1 a 1), cada 14 días o más (Binance no cobra semanal) y con la cuota más alta, IVA incluido, dentro del tope. En la página /sub/{publicId} el cliente ve Binance junto a los bancos.
- Autorizar. El cliente elige Binance y deja su correo. Si el primer cobro es hoy, paga la cuota 1 y firma el contrato en el mismo paso; si no, solo firma. El código para firmar vence en una hora.
- Activa. Al firmar, la suscripción pasa a
activeconmandate.provider: "binance"y salemandate.authorized. - Cada cuota. Binance exige avisar el cobro antes: pagolink lo avisa entre 2 y 5 días antes y cobra pasadas las 24 h del aviso. Por eso «cobrar ahora» responde
status: "scheduled"conreason: "binance_notice"y el cobro sale al día siguiente. El resultado llega como cualquier cuota:subscription.charged, o los reintentos y el enlace de contingencia si no pasa. - Cancelar. Cancelar la suscripción termina el contrato en Binance. Si el cliente lo cancela desde Binance, el plan sigue y pasa a cobro por enlace (
subscription.collection_method_changedconreason: "binance_terminated").
Los cobros sueltos (POST /v1/subscriptions/{id}/charges) no van por el contrato: responden 409 BINANCE_SCHEDULED_CHARGE_UNSUPPORTED; cóbralos con un enlace de pago.
PUT /v1/binance-config/direct-debit { "enabled": true, "limit": "200" } # solo consola; cuenta en vivo
POST /v1/sub/{publicId}/binance { "payerEmail": "…" } # la página del cliente: crea el contrato
POST /v1/sub/{publicId}/binance/check # «Ya aprobé»: consulta la firma
| Código | HTTP | Qué pasó |
|---|---|---|
BINANCE_NOT_AVAILABLE | 409 | El plan no se puede cobrar con Binance (reason: not_enabled, currency, interval o limit). |
BINANCE_CONTRACT_FAILED | 409 | Binance no creó la autorización; intenta de nuevo. |
BINANCE_SCHEDULED_CHARGE_UNSUPPORTED | 409 | Un cobro suelto sobre una suscripción que cobra Binance. |
En modo prueba no se llama a Binance: el contrato se firma con «Simular aprobación» y cada cuota sale cobrada. Un cliente cuyo correo empieza por fail rechaza los cobros, para probar los reintentos.
Cashea
Compra ahora y paga en cuotas. Fase 1: solo cobros en USD (una sesión en Bs no ofrece Cashea) y la inicial la paga el cliente en su app de Cashea. El resto lo financia Cashea y te lo deposita a ti, no pagolink. La comisión de pagolink es sobre el total, como cualquier cobro; no hay IGTF, porque la inicial en la app la maneja Cashea.
Cómo se configura (consola › Ajustes › Cashea). No depende de tu banco. Si tu tienda ya vende con Cashea, empieza en el paso 2:
- Afíliate a Cashea como comercio: llena su formulario de afiliación; Cashea revisa, te asigna un ejecutivo y activa tu tienda.
- Pide tu API Key a tu ejecutivo.
- Pega la API Key y elige la caja.
- Indica dónde te deposita Cashea.
- Enciende Cashea en el checkout (solo cobros en dólares).
Configuración (por entorno): tu API Key de Cashea (se guarda cifrada y no se vuelve a mostrar), la caja contra la que se crean las órdenes, encendido y la cuenta donde deposita Cashea (informativa). En modo prueba va contra el staging de Cashea; sin API Key de pruebas, el checkout de prueba simula Cashea (la cédula 1111 simula un rechazo).
Estas rutas son solo desde la consola: piden la sesión de la consola, no la clave secreta. Con sk_… responden 401.
GET /v1/cashea-config?environment=test # estado, sin la clave
PUT /v1/cashea-config/credentials { "apiKey": "…", "environment": "test" }
GET /v1/cashea-config/pos?environment=test # cajas de tu cuenta de Cashea
PUT /v1/cashea-config { "posUuid": "…", "active": true, "depositAccount": { "bank", "accountNumber", "holder" }, "environment": "test" }
POST /v1/cashea-config/probe?environment=test # ¿responde Cashea con esta clave?
Checkout (público, con x-session-token). El checkout hospedado ya lo hace; esto es para quien arma su propio checkout:
POST /v1/checkout/sessions/{id}/cashea { "identificationNumber"?: "12345678", "payer"?: { … } }
→ { "orderUuid", "idNumber", "expiresAt" (5 min), "posName", "simulated" }
POST /v1/checkout/sessions/{id}/cashea/code { "userCode": "123456" } # el código de seguridad de la app
GET /v1/checkout/sessions/{id}/cashea # consultar cada ~3 s
→ { "state": "waiting" | "down_payment_pending" | "completed" | "expired" | "rejected", "financing"? }
DELETE /v1/checkout/sessions/{id}/cashea # abandonar: cancela la orden y el cobro vuelve a pendiente
Crear la orden toma el cobro: mientras está abierta no se puede pagar por otra vía. Vence a los cinco minutos; si se abandona, vence o Cashea no aprueba, la orden se cancela y el cobro vuelve a pendiente. Con la inicial pagada en la app el cobro queda completed, y payment (sesión, transacciones y webhook) trae financing:
"payment": { "method": "cashea", "currency": "USD", "amount": "25.00",
"financing": { "provider": "cashea", "orderUuid": "…", "idNumber": 923054, "downPayment": "15.00", "financed": "10.00",
"downPaymentStatus": "PAID", "installments": [ { "number": 1, "date": "2026-10-13", "amount": "3.33", "status": "SCHEDULED" }, … ] } }
Si Cashea deja la inicial pendiente (down_payment_pending), el pagador tiene que pagarla desde su app; cobrarla con otro método de pagolink llega en la fase 2.
Facturación
Una API de facturación aparte de los cobros: factura cualquier venta —también las que no cobraste con pagolink— y pagolink la emite por la imprenta digital autorizada. Las facturas que salen solas de cada cobro (factura digital de cada cobro) aparecen en la misma lista, con source: "transaction" y el cobro en transaction. Va con la clave secreta sk_…, y el entorno lo decide la clave.
POST /v1/invoices # cabecera opcional Idempotency-Key → 201 factura
GET /v1/invoices # ?status=&from=&to=&taxId=&reference=&number=&transaction=&limit=&offset=
GET /v1/invoices/numbering-ranges # rangos de numeración del emisor
GET /v1/invoices/{id} # inv_…
GET /v1/invoices/{id}/pdf # { filename, contentType, contentBase64, sample }
POST /v1/invoices/{id}/send # { email? } → correo con el enlace del PDF
POST /v1/invoices/{id}/credit-notes # { reason, lines? } → 201 nota de crédito
POST /v1/invoices/{id}/void # { reason } → la factura anulada
En vivo hace falta la facturación activada (RIF, razón social y domicilio fiscal) y la imprenta digital lista. En prueba no hace falta nada.
Crear una factura
curl https://api.pagolink.co/v1/invoices \
-H "x-api-key: $PAGOLINK_SK" -H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-12487" \
-d '{
"customer": { "taxId": "J-50239702-7", "name": "Panadería La Esquina, C.A.", "address": "Av. Bolívar, Caracas", "email": "compras@laesquina.com" },
"currency": "USD", "pricesIncludeTax": false,
"lines": [
{ "description": "Harina de trigo 1 kg", "quantity": "10", "unitPrice": "1.50" },
{ "description": "Libro de recetas", "quantity": 1, "unitPrice": "20.00", "taxCategory": "REDUCED", "discount": "2.00" }
],
"payments": [ { "method": "zelle", "currency": "USD", "amount": "36.84" } ],
"reference": "pedido-12487", "metadata": { "orderId": "12487" }, "sendEmail": true
}'
| Campo | Tipo | Qué es |
|---|---|---|
customer | objeto | taxId (RIF o cédula; se normaliza a J-50239702-7), name y address obligatorios (la factura digital exige el domicilio del comprador), email y phone opcionales. |
currency | texto | VES, USD o EUR. USDT se declara como USD. |
lines | lista | De 1 a 100: description, quantity (hasta 4 decimales), unitPrice, taxCategory, discount (monto de la línea) y code. |
taxCategory | texto | STANDARD (16 %, por defecto), REDUCED (8 %), LUXURY (31 %), ZERO, EXEMPT, EXONERATED, NOT_SUBJECT. |
pricesIncludeTax | booleano | true: el precio trae el IVA y la factura cierra en lo pedido (a ±0,01 si ninguna base cierra exacto). false: el IVA va encima. Por defecto, el de tu configuración fiscal. |
payments | lista | Cómo se pagó: method (pago_movil, c2p, transfer, immediate_debit, domiciliacion, binance, zelle, paypal, cash, pos_card, crypto, other), currency, amount, amountInInvoiceCurrency. Obligatorio si la factura no es en bolívares: el IGTF depende de cómo se pagó. |
fxRate | decimal | Bolívares por unidad de la moneda de la factura. Sin él, la tasa BCV del día. |
reference, metadata, transaction | — | Tus referencias (metadata con las reglas de las sesiones) y, si quieres, el publicId de un cobro de pagolink para vincularla. |
sendEmail | booleano | Manda la factura al correo del cliente en cuanto tenga número. |
Idempotency-Key: la misma clave con el mismo cuerpo devuelve la misma factura; con otro cuerpo, 409 IDEMPOTENCY_KEY_REUSED. Si quedó failed, repetir con la misma clave la vuelve a intentar.
Objeto factura
{ "id": "inv_8f1c…", "object": "invoice", "environment": "live", "source": "api",
"status": "issued", // draft | pending | issued | failed | voided
"number": "00000042", "controlNumber": "00-00000042",
"currency": "USD", "fxRate": "853.49930000", "fxRateDate": "2026-09-30", "fxRateSource": "bcv",
"pricesIncludeTax": false,
"customer": { "taxId": "J-50239702-7", "name": "Panadería La Esquina, C.A.", "address": "…", "email": "…", "phone": null },
"lines": [ { "lineNumber": 1, "description": "Harina de trigo 1 kg", "quantity": "10", "unitPrice": "1.50", "taxCategory": "STANDARD",
"taxRate": "16.00", "discount": "0.00", "taxableBase": "15.00", "tax": "2.40", "total": "17.40" }, … ],
"payments": [ { "method": "zelle", "currency": "USD", "amount": "36.84", "amountInInvoiceCurrency": null } ],
"totals": { "taxableBase": "33.00", "discount": "2.00", "tax": "3.84", "igtf": "1.11", "total": "36.84" },
"creditNotes": [], "transaction": null, "reference": "pedido-12487", "metadata": { "orderId": "12487" },
"pdfUrl": "/v1/invoices/inv_8f1c…/pdf", "failureReason": null, "voidReason": null, "voidedAt": null,
"issuedOn": "2026-09-30", "issuedAt": "…", "createdAt": "…", "updatedAt": "…" }
pending quiere decir que la imprenta todavía no confirmó: la factura digital se transmite en cola y pasa a issued sola, con su webhook. failed trae el motivo en failureReason. totals.total es base más IVA; el IGTF (3 % de lo pagado en divisas fuera de la banca nacional: efectivo, Zelle, cripto) se percibe aparte y va en totals.igtf. Importes como texto con 2 decimales; unitPrice es el neto del documento fiscal.
Listar, ver, descargar y enviar
curl "https://api.pagolink.co/v1/invoices?status=issued&from=2026-09-01&to=2026-09-30&taxId=J-50239702-7&limit=25" \
-H "x-api-key: $PAGOLINK_SK"
# → { "object": "list", "data": [ … ], "total": 3, "hasMore": false } + cabecera X-Total-Count
curl https://api.pagolink.co/v1/invoices/inv_8f1c…/pdf -H "x-api-key: $PAGOLINK_SK" \
| jq -r .contentBase64 | base64 -d > factura.pdf
curl https://api.pagolink.co/v1/invoices/inv_8f1c…/send -H "x-api-key: $PAGOLINK_SK" \
-H "Content-Type: application/json" -d '{ "email": "otra@laesquina.com" }'
# → { "object": "invoice_delivery", "invoice": "inv_8f1c…", "to": "otra@laesquina.com", "status": "sent", "url": "…" }
Filtros: status, from/to (día de creación, hora de Venezuela), taxId del cliente, reference, number y transaction; paginación con limit (1 a 100) y offset. El PDF es el de la imprenta autorizada y viaja en base64 dentro del JSON. El correo lleva un enlace firmado (vence a los 90 días) para descargarlo sin clave; sin email va al del cliente. Solo se envía una factura issued; si está pending, responde status: "scheduled" y el correo sale cuando la imprenta la emita.
Notas de crédito, anulación y numeración
curl https://api.pagolink.co/v1/invoices/inv_8f1c…/credit-notes -H "x-api-key: $PAGOLINK_SK" \
-H "Content-Type: application/json" \
-d '{ "reason": "Devolución de mercancía", "lines": [ { "lineNumber": 1, "quantity": "3" } ] }'
# → 201 { "id": "cn_…", "object": "credit_note", "invoice": "inv_8f1c…", "status": "issued", "number": "00000007",
# "reason": "Devolución de mercancía", "totals": { "taxableBase": "4.50", "tax": "0.72", "total": "5.22" } }
curl https://api.pagolink.co/v1/invoices/inv_8f1c…/void -H "x-api-key: $PAGOLINK_SK" \
-H "Content-Type: application/json" -d '{ "reason": "Emitida por error" }'
curl https://api.pagolink.co/v1/invoices/numbering-ranges -H "x-api-key: $PAGOLINK_SK"
# → { "object": "list", "data": [ { "prefix": "00", "rangeFrom": "1", "rangeTo": "10000", "nextValue": "43", "remaining": "9958",
# "usagePercent": 0, "isExhausted": false, "needsAttention": false, … } ] }
La nota de crédito corrige la factura sin anularla: sin lines acredita la factura completa; cada línea va por su lineNumber y quantity no puede pasar de lo facturado. Los precios salen de la factura y el descuento de la línea no se arrastra. Solo se anula o se acredita una factura issued; el número anulado no se reutiliza. needsAttention marca los rangos que pasaron el 80 % o vencen en menos de 30 días.
Modo prueba
Con sk_test_… no se llama a la imprenta, no se exige la facturación activada y nada tiene validez fiscal. La factura sale issued de inmediato con número TEST-00000001 y control TEST-00-00000001, los totales calculados igual que en vivo (IVA por línea e IGTF) y el aviso en testNotice. El PDF es una muestra (sample: true), send responde status: "simulated" sin mandar correo y las notas salen TEST-NC-….
Errores
| Código | HTTP | Cuándo |
|---|---|---|
INVOICING_DISABLED | 409 | La facturación no está activada. |
ISSUER_INCOMPLETE | 409 | Faltan el RIF, la razón social o el domicilio fiscal del comercio. |
ISSUER_NOT_READY | 409 | La imprenta digital todavía no está lista para emitir. |
TAX_SERVICE_UNAVAILABLE | 503 | El servicio fiscal no está disponible; reintenta más tarde. |
FX_RATE_UNAVAILABLE | 409 | Factura en divisas sin fxRate y sin tasa BCV cargada. |
PAYMENTS_REQUIRED | 400 | Factura en divisas sin payments. |
IDEMPOTENCY_KEY_REUSED | 409 | La Idempotency-Key ya se usó con otro cuerpo. |
INVOICE_NOT_ISSUED | 409 | Nota, anulación o correo sobre una factura no emitida. |
INVOICE_ALREADY_VOIDED | 409 | La factura ya está anulada; una nota de crédito sobre una factura anulada responde con este código. |
PDF_UNAVAILABLE | 409 | La factura no tiene número todavía, o la imprenta no devolvió el archivo. |
INVALID_CREDIT_NOTE_LINE | 400 | La nota nombra una línea que no existe o acredita más de lo facturado. |
CREDIT_NOTE_REJECTED, VOID_REJECTED | 422 | El servicio fiscal rechazó la nota o la anulación; el mensaje dice por qué. |
TAX_SERVICE_ERROR | 502 | El servicio fiscal no respondió; reintenta. |
INVOICE_NOT_FOUND / TRANSACTION_NOT_FOUND | 404 | La factura o el cobro no existen en el entorno de tu clave. |
CUSTOMER_EMAIL_REQUIRED | 400 | send sin email y el cliente no tiene correo. |
Webhooks: invoice.issued, invoice.failed e invoice.voided traen la factura en data; invoice.credit_note.issued, la nota de crédito. Van a los mismos endpoints y con la misma firma que los de cobros (Webhooks).
Tasas BCV
pagolink guarda la tasa oficial del BCV una vez al día (23:59 de Venezuela, ya con la del próximo día hábil publicada) y con ella cobra los precios en divisas. Las tasas son bolívares por unidad de divisa, con 8 decimales, como las publica el BCV. En fin de semana o feriado rige la que el BCV ya publicó para el próximo día hábil.
GET /v1/exchange-rates/current?currency=USD # pública; USD por defecto, o EUR
→ { "currency": "USD", "rate": "853.49930000", "valueDate": "2026-09-23",
"publishedAt": "2026-09-23T19:03:39.840Z", "source": "bcv" }
GET /v1/exchange-rates/convert?from=USD&amount=10 # to: por defecto la moneda local
→ { "from": "USD", "to": "VES", "amount": "8534.99", "rate": "853.49930000",
"rateSource": "bcv", "valueDate": "2026-09-23", "decimals": 2 }
GET /v1/exchange-rates?currency=EUR&from=2026-09-01&to=2026-09-30 # historial, con tu clave
→ [{ "currency": "EUR", "rate": "976.54829408", "valueDate": "2026-09-23", "publishedAt": "…", "source": "bcv" }, …]
| Parámetro | Qué es |
|---|---|
currency | USD (por defecto) o EUR. |
from, to (convert) | Monedas del par; una de las dos es la local (VES), y local también vale. Sin to, el destino es la moneda local del negocio si mandas tu clave, o VES si no. |
amount (convert) | Monto como texto, "10.00". Sin él devuelve solo la tasa. El resultado va con los decimales de la moneda destino (decimals). |
from, to (historial) | Rango de fecha valor, YYYY-MM-DD. Hasta 400 tasas, de la más nueva a la más vieja. |
| Error | Cuándo |
|---|---|
400 | Una moneda que la fuente no publica, un par sin la moneda local, un monto o una fecha inválidos. |
401 | Clave o sesión inválida. convert funciona sin credenciales, pero si las mandas tienen que ser válidas. |
404 | current cuando todavía no hay ninguna tasa cargada. |
409 | convert sin tasa cargada para esa divisa, o un negocio con una moneda local sin fuente de tasas. No cobres con un valor inventado: reintenta más tarde. |
Métodos de pago
Nueve métodos, una sola integración. Lo que cambia entre ellos es quién da el pago por bueno.
| Método | Comprobación | Qué pide |
|---|---|---|
| Pago Móvil | Automática | El cliente paga desde su banco; pagolink comprueba el pago entrante contra la cuenta. |
| C2P | Automática | Banco, teléfono y clave C2P del cliente. |
| Débito Inmediato | Automática | OTP de 8 dígitos enviado por SMS al cliente. |
| Transferencia | Automática | Datos de cobro; se comprueba el abono contra la cuenta. |
| Tarjeta | Automática | Tarjetas Visa y MasterCard, y débito según lo que declare el banco. Solo si tu banco la ofrece y tiene afiliación de tarjeta (ver Tarjeta). |
| Binance Pay | Automática | QR; el cliente escanea y paga en USDT, que entra a tu cuenta de Binance Pay. |
| Cobro manual | Manual | Un método que el comercio nombra y describe (Zelle, Zinli, efectivo…), en USD o Bs: el pagador sigue sus instrucciones y da la referencia; el comercio confirma el pago. Si el comercio no lo configuró y encendió en la consola, no aparece en el checkout y pagar con él responde 400 «Método de cobro no soportado en checkout». |
| PayPal | Manual | La cuenta PayPal del comercio; el comercio confirma el pago recibido. |
| Transferencia internacional | Manual | Los datos bancarios del comercio en el exterior; el comercio confirma el pago recibido. |
0138).Proveedores y estado de cada servicio
La pantalla Métodos de la consola agrupa los servicios por proveedor (Banco Plaza, Binance Pay, Cashea y el cobro manual) y dice en qué va cada uno para tu cuenta. El banco va como tabla: qué se puede cobrar hoy y qué abre tu banco activo. Lo lee de GET /v1/providers?environment=live|test, que pide la sesión de la consola (no la clave secreta). En los métodos del banco que cobran hoy, mode (manual | auto) dice si lo confirmas tú o se confirma solo.
| Estado | Qué significa |
|---|---|
active / off | Encendido o apagado por ti. |
can_activate | Tu banco ya lo aprobó; falta encenderlo. |
needs_connection | Tu cuenta de cobro no lo incluye; se pide al banco. |
pending / rejected | La solicitud al banco está en trámite o fue rechazada. |
rejected_by_bank | El banco no habilitó ese proceso (bankCode trae su código). |
needs_credentials | Binance Pay: falta tu API key y tu API secret del entorno. Cashea: falta la clave o la caja. |
needs_setup | Cobro manual: falta su nombre e instrucciones. |
coming_soon | Todavía no se ofrece (envío de Pago Móvil y de transferencias). |
simulated | Modo prueba, métodos del banco: se simulan. Binance Pay, Cashea y el cobro manual salen según su configuración también en prueba. |
Tarjeta
Cobro con tarjeta Visa, MasterCard y débito a través del banco del comercio. Hoy lo ofrece BNC, el primer banco con este método. El monto va en bolívares, como en el resto de los métodos.
Disponibilidad
El método se ofrece solo si el banco de la cuenta del proyecto lo ofrece (hoy BNC) y la cuenta tiene afiliación de tarjeta del banco. Lo carga el equipo de pagolink, no el comercio. Sin afiliación, tarjeta no aparece en paymentMethods de la sesión y pagar con él responde un error 4xx sin tocar el banco.
Flujo
El pagador escribe la tarjeta en el checkout alojado. No hay ruta para enviar tarjetas desde tu servidor: tú solo creas la sesión y llevas al pagador al checkout. El formulario se adapta a lo que declara el banco; por ejemplo, el débito Maestro de BNC pide además PIN y tipo de cuenta, y Visa y MasterCard no.
Resultado
En la sesión completada, payment.card trae { brand, last4 } (brand: visa, mastercard o maestro). El mismo dato queda en metadata.card del cobro, con provider: "tarjeta".
| Respuesta | Qué significa |
|---|---|
payment_retry | Un dato se puede corregir; la sesión sigue pending y el pagador reintenta. |
payment_declined | El banco rechazó el pago de forma definitiva (failed, retryable: false). El checkout ofrece otro método. |
payment_pending_reconciliation | El banco no respondió. La sesión queda processing y pagolink la concilia sola. |
Reembolsos
En esta versión los pagos con tarjeta no se reembolsan por la API. Se gestionan con el banco.
Webhooks
pagolink hace POST del evento al endpoint que registres en la consola. El callback del navegador es UX; esto es la fuente de verdad.
El cuerpo (envelope v2)
{
"id": "evt_9f2c4a1b8e0d7c3a5b6f1e2d", // estable entre reintentos
"type": "payment.completed",
"created": "2026-09-01T18:22:41.003Z",
"livemode": true, // false = entorno de pruebas
"data": { /* la transacción entera, con tu metadata; con productos, también lineItems */ },
"previous": { "status": "processing" } // sólo lo que cambió
}
id es la clave de deduplicación. Un reintento llega con el mismo id: guárdalo y descarta lo repetido — es lo que evita cerrar dos veces la misma factura. No uses created ni el contenido para deducirlo.
{ event, data }: sin id, sin created y sin livemode, así que no se puede deduplicar ni distinguir live de pruebas. Se mantiene por compatibilidad: un endpoint recibe el envelope v2 sólo cuando declara la versión 2.Cabeceras
| Cabecera | Para qué |
|---|---|
X-Pagolink-Signature | Firma v2, v1=<hex> — HMAC-SHA256 de "<timestamp>.<cuerpo crudo>" con el secreto del endpoint. Es la que hay que verificar. |
X-Pagolink-Timestamp | Epoch en segundos. Va dentro de la firma, así que la firma caduca. Tolerancia sugerida: 5 minutos. |
X-Payment-Signature | Firma histórica v1, sha256=<hex>. Solo se envía a endpoints v1, por compatibilidad; los endpoints nuevos (v2) no la reciben. No la uses: no cubre el tiempo, y un payload capturado se reenvía indefinidamente con la firma válida. |
Los endpoints nuevos se crean en versión 2. data lleva los campos públicos del cobro (id, publicId, status, amount, currency, provider, providerRef, reference, comisión, neto, fechas y environment), tu propio metadata, payer si lo pediste, y lineItems, discount y payment cuando aplican. Nunca el token de la sesión ni datos internos.
Integradores (canal): el webhook del canal se firma con un secreto por comercio: "whsec_" + HMAC_SHA256(secreto del canal, projectId) en hex. Calcúlalo con el projectId que devuelve el alta.
Verificar la firma
Firma el cuerpo crudo. Verifica sobre los bytes exactos que llegaron, antes de parsear el JSON. Si reserializas el objeto la firma no cuadra nunca: el orden de las claves y los espacios cambian. En Express, express.raw(), no express.json().
const { createHmac, timingSafeEqual } = require('node:crypto');
function verificar(cuerpoCrudo, firma, timestamp, secreto, toleranciaSeg = 300) {
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return false;
// Sin esto la firma no caduca y un payload capturado vale para siempre.
const ahora = Math.floor(Date.now() / 1000);
if (Math.abs(ahora - ts) > toleranciaSeg) return false;
const esperada = 'v1=' + createHmac('sha256', secreto)
.update(`${ts}.${cuerpoCrudo}`).digest('hex');
const a = Buffer.from(esperada), b = Buffer.from(firma);
return a.length === b.length && timingSafeEqual(a, b);
}
app.post('/hooks/pagolink', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verificar(
req.body.toString('utf8'),
req.get('X-Pagolink-Signature'),
req.get('X-Pagolink-Timestamp'),
process.env.PAGOLINK_WEBHOOK_SECRET,
);
if (!ok) return res.status(401).end();
const evento = JSON.parse(req.body.toString('utf8'));
if (yaVisto(evento.id)) return res.status(200).end(); // deduplicar por id
res.status(200).end(); // responde YA; si tardas, pagolink reintenta
encolar(evento);
});
Catálogo de eventos
| Evento | Cuándo | Qué hacer |
|---|---|---|
payment.completed | El pago entró | Cerrar la factura. El dinero ya está en la cuenta |
payment.failed | Rechazado | Dejar la factura abierta |
payment.expired | El enlace caducó sin pagarse | Dejar la factura abierta |
payment.pending_review | Esperando comprobación | «Verificando». Aún no cierres nada |
payment.updated | Cualquier otro cambio de estado de un cobro, sin evento propio | Leer data.status antes de actuar |
refund.completed | Devuelto | Reabrir o abonar |
mandate.authorized | El cliente autorizó y el banco aprobó la domiciliación: la autorización queda activa | Activar el acceso al plan |
subscription.charged | Cobro del plan liquidado, o marcado pagado (paidOutside: true) | Cerrar la cuota |
subscription.payment_link_sent | Se abrió el cobro de una fecha de un plan que se cobra por enlace y salió su enlace de pago | Informar al cliente si quieres avisarle tú; el cobro llega como subscription.charged |
subscription.collection_method_changed | El plan pasó de domiciliación a cobro por enlace: el banco del pagador no domicilia, el banco rechazó la domiciliación o el pagador lo eligió | Registrar la nueva forma de cobro; el plan sigue activo |
subscription.created | Se creó la suscripción | Guardar el sub_… |
subscription.updated | Cambió la metadata, el externalId o el monto de los próximos cobros, o se programó o deshizo su cancelación. previous trae lo anterior | Sincronizar tu registro |
subscription.charge_failed | Falló un cobro del plan. Trae attempt y nextAttemptAt (null si no habrá otro intento) | Avisar al cliente si nextAttemptAt es null |
subscription.charge_review_required | El banco no confirmó un cobro del plan en 72 horas y quedó por revisar | Revisar tu estado de cuenta y resolverlo |
subscription.past_due | Se acabaron los intentos (o el banco rechazó de forma definitiva) y la suscripción quedó en mora | Avisar al cliente; no cierres la cuota |
subscription.canceled | La suscripción se canceló. cancelReason dice por qué | Dejar de dar el servicio |
subscription.upcoming_charge | Faltan menos de 48 horas para el próximo cobro del plan (un aviso por ciclo) | Avisar al cliente antes del cobro |
subscription.cycle_uncollectible | Cerraste un ciclo como incobrable a mano | Registrar la cuota como perdida |
subscription_charge.created | Se programó un cobro puntual de la suscripción | Guardar el sch_… |
subscription_charge.completed | Cobrado, o marcado pagado (paidOutside: true) | Cerrar el cargo: es dinero recibido |
subscription_charge.failed | Falló un intento. data.willRetry dice si habrá otro | Con willRetry: false, avisar o marcarlo pagado |
subscription_charge.canceled | Cancelado a mano o al cancelarse la suscripción | Quitarlo de tus cobros pendientes |
invoice.issued | La factura quedó emitida (número y control asignados) | Guardar el número y el PDF |
invoice.failed | La factura no se emitió | Revisar failureReason, corregir y reintentar |
invoice.voided | La factura se anuló | Marcarla anulada |
invoice.credit_note.issued | Se emitió una nota de crédito | Registrar el abono |
merchant.onboarding.bank_pending | El operador envió la solicitud de la cuenta de cobro al banco | Mostrar «en trámite con el banco» |
merchant.onboarding.returned | Falta información: el trámite vuelve al comercio para completarlo | Pedir al comercio lo que indica el trámite |
merchant.onboarding.rejected | El trámite de la cuenta de cobro fue rechazado | Mostrar el motivo y no habilitar cobros |
merchant.onboarding.active | La cuenta de cobro quedó activa con su banco | Habilitar los métodos que indique methods |
merchant.method.enabled | Se habilitó un método de cobro del banco; llega un evento por método | Mostrar el método como disponible |
webhook.test | Evento de prueba enviado desde la consola | Ninguna: solo confirma la ruta |
Datos de cada evento
| Evento | data |
|---|---|
payment.*, refund.completed | La transacción pública, con metadata solo de tus claves (ver metadata). |
subscription.*, mandate.authorized | La suscripción con la forma de su consulta: publicId (sub_…), status, amount, currency, interval, nextChargeAt, lastChargeAt, retryCount, cancelReason, canceledAt, discount, metadata y mandate (estado, nombre, banco, últimos 4 de la cuenta). Los campos id y projectId son internos de pagolink: guarda publicId. |
subscription.charged | Lo anterior, más transactionId. Si lo marcaste tú, además paidOutside: true. |
subscription.canceled | Lo anterior. cancelReason es merchant_requested (lo cancelaste tú), payer_requested (lo canceló el cliente desde el portal) o auto_past_due (cancelación automática por mora). |
subscription.payment_link_sent | Lo anterior, más sessionId (txn_… de la sesión de pago), periodDue (fecha del cobro), graceEndsAt (hasta cuándo se puede pagar), amount y currency. |
subscription.collection_method_changed | Lo anterior, con collectionMethod: "payment_link" y reason: bank_not_allowed (el banco del pagador no domicilia), bank_declined (el banco rechazó la domiciliación) o payer_choice (el pagador pidió pagar con enlace). |
subscription.upcoming_charge | Lo anterior, más nextChargeAt, amount (monto del próximo cargo, con su descuento) y currency. |
subscription.cycle_uncollectible | Lo anterior, más note (o null). |
subscription_charge.* | El cobro programado con la forma de su respuesta (id sch_…, status, attempts, failureReason, transactionId, paidOutside, metadata…). .failed agrega willRetry. |
merchant.onboarding.bank_pending | projectId, provider (por ejemplo banco-plaza) y caseRef (la referencia del banco, o null). |
merchant.onboarding.returned, merchant.onboarding.rejected | projectId, provider y reason: el motivo que escribió el operador. |
merchant.onboarding.active | projectId, provider, methods (los métodos pedidos: pago-movil, transferencia, c2p, cobro-inmediato, domiciliacion) y accounts (cuántas cuentas quedaron activas). |
merchant.method.enabled | projectId, provider y method: uno de los ids de methods, con la misma forma (pago-movil, no pago_movil). |
Los eventos de suscripción y de cobros programados traen livemode: false cuando la suscripción es de prueba, en el sobre y en el objeto. Los merchant.* son del alta del negocio y siempre salen con livemode: true.
Sólo payment.completed, subscription.charged y subscription_charge.completed significan dinero recibido. payment.pending_review es el traicionero: parece un pago y todavía no lo es.
Número del cobro: number
Cada cobro tiene un number legible, como 2381-0293: 8 dígitos con un guion en medio, sin cero al inicio. Es el que lee tu cliente (en su comprobante, el correo de aviso y el checkout: «Cobro 2381-0293») y el que puedes buscar en la consola. Es único por comercio, no por cuenta global, y se puede buscar con o sin guion (GET /v1/transactions?q=23810293). Viene en la sesión que creas, en GET /v1/transactions, en GET /v1/transactions/{id} y en data.number del webhook. El id estable para integraciones sigue siendo id (o publicId, txn_…): guarda ese, no el número.
Lo que se cobró: payment
Un cobro en dólares se paga en bolívares a la tasa BCV, o en USDT por Binance. payment dice cuánto salió de verdad de la cuenta del pagador, con qué método y a qué tasa. Viene en cada transacción (GET /v1/transactions y GET /v1/transactions/{id}, con la clave secreta o desde la consola), en el webhook como data.payment y en la consulta pública de la sesión, esta última solo con la cabecera x-session-token y el cobro completado. Es null mientras no se eligió método o si no hay de dónde saberlo: nunca se recalcula con la tasa de hoy.
{ "amount": "10.00", "currency": "USD", "status": "completed",
"payment": { "method": "immediate_debit", "currency": "VES", "amount": "8578.88",
"rate": "857.88760000", "rateSource": "bcv", "rateValueDate": "2026-09-29",
"paidAt": "2026-09-29T20:21:07.000Z" } }
rate es null cuando se pagó en la moneda del cobro, sin conversión. rateSource es bcv o merchant (la tasa o los montos que mandaste tú). La exportación de cobros (GET /v1/transactions/export?format=csv) trae las columnas id, publicId, amount, currency, status, provider, reference, createdAt, paidAmount, paidCurrency, rate, rateSource, payerReference, bankReference, payerName, payerDocument, payerEmail.
Pagar con otro método tras un rechazo
Si el banco rechaza un C2P o un Débito Inmediato (por ejemplo E001, el pagador no tiene el servicio activo), el checkout le ofrece pagar el mismo monto con otro método. Para ti son dos cobros:
- El rechazado sigue siendo
payment.failed, igual que siempre. No cambia. - pagolink abre un cobro nuevo por el mismo monto, con tu misma
description,reference, URLs de retorno y tumetadata, másmetadata.retryOfcon elsessionIddel rechazado (ymetadata.retryDepth, de 1 a 3). Sus eventos llegan como los de cualquier cobro.
Si cierras facturas por metadata.invoiceNumber (o tu propia clave) no tienes que hacer nada: el cobro nuevo la trae. Si guardas el sessionId, relaciona los dos por retryOf. Hay un tope de tres reintentos encadenados.
No se ofrece en Pago Móvil ni en transferencia: ahí el pagador ya movió el dinero por su cuenta, y abrirle otro cobro sería invitarlo a pagar dos veces.
Reintentos
Se considera entregado con cualquier 2xx: responde rápido y procesa después. Si no, pagolink reintenta con espera creciente y corta al quinto intento.
| Intento | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| Espera | 60 s | 5 min | 15 min | 1 h | 2 h |
Para probar la ruta, la firma y el cierre antes de vivo, ver Probar webhooks.
Cuenta pagolink del pagador y portal
Quien ya pagó con pagolink puede guardar sus datos para el próximo cobro, en cualquier comercio de pagolink. Los datos son de la persona y no del comercio: el comercio nunca ve el perfil. El pagador entra con su correo y un código de 6 dígitos; el mismo acceso sirve al portal, donde ve sus pagos, sus facturas y sus suscripciones, y puede cancelar una suscripción o olvidar sus datos.
Lo que se guarda: correo, nombre, documento, teléfono, y por método los datos que el pagador escribió (banco, teléfono y documento del pago). Nunca se guardan claves C2P, códigos SMS, referencias ni montos. Los datos personales van cifrados en reposo. Si el ambiente no tiene la clave del cofre configurada, la función queda apagada: la sesión no trae payerAccounts y el pagador no ve la opción.
Recordar al pagar
Al pagar, el cuerpo de POST /v1/checkout/sessions/{id}/pay puede traer rememberPayer: true y, si el pagador ya verificó su correo, payerToken. El perfil se guarda cuando el cobro pasa a completed, no al enviar el formulario: un cobro rechazado no deja nada. Sin payerToken, un perfil que ya existe no se toca; solo se crea si no hay cuenta. Así nadie pisa los datos de otra persona tecleando su correo.
Entrar al checkout con la cuenta
POST /v1/checkout/sessions/{id}/payer/code # { "email": "ana@ejemplo.com" } → 202 { "sent": true }
POST /v1/checkout/sessions/{id}/payer/verify # { "email", "code" } → 200 { "profile": {…} | null, "payerToken": "…" }
La respuesta de payer/code es la misma exista o no el correo: nunca revela si hay una cuenta. El código vence en 10 minutos, sirve una sola vez y deja de valer tras cinco intentos malos. Pedir un código nuevo invalida el anterior. El payerToken dura 30 minutos y solo vale para esa sesión y ese correo.
profile tiene la forma: email, name, document {type V, E, J, G o P; number}, phone, methods (pago_movil, c2p, debito_inmediato, transferencia; cada uno con bankCode, phone y document) y billing (name, document, address). Todo es opcional excepto email. El pagador igual confirma el cobro, y el banco sigue pidiendo su clave o SMS.
Portal del pagador
POST /v1/portal/code # { "email", "project"?: "pk_…" } → 202 { "sent": true }
POST /v1/portal/verify # { "email", "code" } → 200 { "token", "expiresAt" }
GET /v1/portal/me # Authorization: Bearer <token>
POST /v1/portal/subscriptions/{id}/cancel
DELETE /v1/portal/profile # «olvidar mis datos» → 204
GET /v1/portal/brand?project=pk_… # nombre, logo y color del comercio, público
GET /v1/portal/me devuelve email, profile, payments, invoices y subscriptions, hasta 50 de cada uno y los más recientes primero. Solo cobros en vivo; las pruebas no aparecen. Cada factura trae downloadUrl firmado, el mismo de su correo. Cancelar una suscripción la hace el servicio de suscripciones: dispara el evento subscription.canceled al comercio, con la causa payer_requested. Un token solo ve y cancela lo de su correo: cancelar una suscripción ajena da 404. Sin token o con uno vencido, 401.
Modo prueba
En una sesión de prueba no sale ningún correo y el código es 000000 (la respuesta trae testCode). En el portal, un correo con dominio .test también usa 000000 sin correo: ese dominio está reservado y nadie lo tiene. Un cobro de prueba no guarda perfil ni aparece en el portal.
Errores
| Código | HTTP | Cuándo |
|---|---|---|
EMAIL_INVALID | 400 | El correo no tiene un formato válido. |
INVALID_CODE | 400 | Código incorrecto, vencido o ya usado. attemptsLeft dice cuántos intentos quedan. |
TOO_MANY_ATTEMPTS | 429 | Cinco intentos malos: el código deja de valer. Pide uno nuevo. |
UNAUTHORIZED | 401 | Falta el token del portal, o venció. |
PUBLIC_KEY_REQUIRED | 400 | La marca del portal pide la clave publicable pk_. |
| 404 | 404 | La sesión, la suscripción o el comercio no existen para ese correo, o la función está apagada. |
Seguridad
- La API key secreta (
sk_…) vive solo en tu servidor. Nunca en el navegador. - El frontend solo usa el publishable key (
pk_…) y elsessionIdde corta vida. - El cobro corre en un iframe del origin de pagolink; el SDK valida el
origindel comercio en cada mensaje. - El endpoint de webhooks debe ser HTTPS y público: pagolink valida la URL antes de cada envío y rechaza las que resuelven a red interna.
- Verifica
X-Pagolink-Signaturesobre el cuerpo crudo y confirma el estado final del lado del servidor antes de entregar el producto o servicio.
Cómo funciona la domiciliación
La domiciliación cobra cada fecha desde la cuenta del cliente, sin que tenga que pagar de nuevo. Los cobros empiezan cuando el cliente aprueba la domiciliación en su banco. Así funciona de principio a fin:
- Autorización. Tú creas la suscripción y el cliente da su autorización de domiciliación en el checkout: su cuenta (20 dígitos) y su cédula. Cada autorización lleva un número de contrato (empieza con
PL) y una fecha de contrato. Es el identificador que el banco del cliente usa para reconocer al comercio. - Cobro de verificación. pagolink envía al banco del cliente un cobro de Bs 1,00 con ese contrato. Si el cliente todavía no aprobó la domiciliación, su banco rechaza el cobro (el motivo es «pendiente de aprobación»; los códigos se listan en Códigos del banco) y en ese momento la solicitud aparece en la banca en línea del cliente.
- Aprobación en el banco. El cliente aprueba la solicitud en la banca en línea de su banco. Cada banco la tiene en un menú distinto; la consola y el correo muestran los pasos de su banco. Entre bancos, la solicitud puede tardar hasta el siguiente día hábil en aparecer, porque los cobros se liquidan en los ciclos del BCV. Mientras tanto, el checkout muestra: «Pendiente de aprobación en tu banco. Apruébala en la banca en línea de tu banco. La solicitud puede tardar hasta el siguiente día hábil en aparecer.»
- Primer cobro. Cuando el banco liquida el cobro de verificación, la autorización queda aprobada y sale el primer cobro del plan. Nunca antes: «enviado al BCV» no significa aprobado.
- Cobros siguientes. Los cobros salen en cada fecha. Si uno falla (fondos insuficientes, límite del cliente
MD15, cliente que suspendió al comercioMD22…), se reintenta según las reglas del comercio. Si no se cobra, el cliente puede pagar ese período con un enlace de pago (ver Enlace de contingencia). - Bancos. Solo algunos bancos permiten domiciliación a sus clientes. Una cuenta de otro banco no completa la autorización (
DOMICILIACION_BANK_NOT_ALLOWED).
Estados de la autorización
| En la consola | En la API | Qué significa |
|---|---|---|
| Pendiente de aprobación | pending_authorization | El cliente todavía no la autorizó, o la autorizó y su banco todavía no la aprobó. El cobro no sale. |
| Aprobada | active | El banco la aprobó (affiliation: "affiliated") y el plan cobra en cada fecha. |
| Revocada | revoked | El cliente o el comercio la cancelaron. No sale ningún cobro automático. |
Aprobación en el banco y cobro de verificación
Un plan se cobra por domiciliación: antes del primer cargo, el banco del cliente tiene que aprobar la domiciliación en su banca en línea. En la API ese paso es la afiliación (affiliation). Hay bancos que lo exigen y otros que no; pagolink lo consulta al banco y lo dice en la página de autorización.
Si el banco lo exige, pagolink envía un cobro de verificación de Bs 1,00 (o el monto que informe el banco). Sirve solo para verificar la cuenta: el banco lo rechaza mientras el cliente no haya aprobado, y ese rechazo es lo que manda la solicitud a su banca en línea. El cobro de verificación no es parte del plan: no es un cobro fallido, no gasta reintentos y no deja la suscripción en mora. En modo prueba no sale nada del banco: el cobro de verificación se simula aceptado.
affiliation_probe (en metadata.trigger).Lo que ve el cliente
GET /v1/sub/{publicId} trae el estado de la aprobación y el monto del cobro de verificación, para que el cliente lo sepa antes de autorizar:
GET /v1/sub/sub_…
# → { "publicId": "sub_…", "status": "active", "mandateStatus": "pending_authorization",
# "affiliation": "pending", "affiliationProbe": { "amount": "1.00", "currency": "VES" },
# "payerBanks": null, … }
| Campo | Valor | Qué significa |
|---|---|---|
affiliation | null | El cliente todavía no autorizó la domiciliación. |
pending | Autorizada. El banco espera que el cliente apruebe la domiciliación en su banca en línea. | |
affiliated | Aprobada por el banco: la autorización está activa y la suscripción cobra. | |
affiliationProbe | { amount, currency } o null | El cobro de verificación que verá el cliente. Es null si el banco no lo exige o la autorización ya está activa. |
Autorizar
El cliente autoriza en pay.pagolink.co/sub/{publicId}. Por API, la misma llamada es POST /v1/sub/{publicId}/authorize (sin clave: el publicId es el secreto):
curl https://api.pagolink.co/v1/sub/sub_…/authorize -H "Content-Type: application/json" \
-d '{ "payerName": "Ana Pérez", "payerId": "V12345678", "payerEmail": "ana@correo.com",
"payerAccount": "01380000000000012345", "payerBank": "0138" }'
# → { "status": "active", "publicId": "sub_…", "affiliation": "pending", "firstCharge": null, "nextChargeAt": "…" }
La cuenta son 20 dígitos y el banco tiene que ser uno que acepte domiciliación (payerBanks).
Si el banco del comercio también domicilia por teléfono, la suscripción lo dice en domiciliationInstruments (["account"] o ["account", "phone"]). Para domiciliar por teléfono se manda "payerInstrument": "phone" y "payerPhone": "04121234567" en lugar de payerAccount. Sin payerInstrument es por cuenta. Pedir teléfono a un banco que no lo acepta responde 400.
La respuesta trae affiliation y el primer cobro según el resultado del cobro de verificación:
affiliation | Primer cobro (firstCharge) | Qué hacer |
|---|---|---|
affiliated | El primer cobro del plan, con su estado (completed, processing…), si la suscripción cobra al autorizar. | Nada. Se escucha mandate.authorized y el cobro llega por su webhook. |
pending | null. El cobro no sale hasta que el banco apruebe. | Mostrar «Pendiente de aprobación en tu banco» y esperar. Ver Quién vuelve a verificar. |
declined | { "status": "failed", "reason": "bank_declined" }. El banco rechazó el cobro de verificación por otra razón. | No hay cargo ni mora. La autorización sigue esperando y se vuelve a verificar como en pending. |
Repetir la verificación: «Ya aprobé»
POST /v1/sub/{publicId}/probe sin cuerpo. El enlace lo usa cuando el cliente dice que ya aprobó; responde con la misma forma que authorize.
- Si la suscripción ya está aprobada, responde
affiliatedsin llamar al banco. - Si el último intento fue hace menos del intervalo mínimo (15 segundos por defecto, o el que informe el banco), no llama al banco: responde
pending. Dos clics seguidos verifican una sola vez. - Si la suscripción no está esperando aprobación, responde
409. Si fue cancelada, también409. - Límite: 6 peticiones por minuto por suscripción y 30 por minuto por IP. Al pasarlo,
429.
Quién vuelve a verificar
- El cliente. Mientras la página
/sub/{publicId}está abierta, consulta el estado; deja de consultar a los diez minutos y el cliente puede volver con «Ya aprobé». Al autorizar, si el banco pide la aprobación, pagolink le envía un correo «Aprueba tu domiciliación» con el monto del cobro de verificación y el enlace. - El worker. Una vez cada 24 horas verifica las suscripciones que siguen esperando. Si el banco las aprobó, la autorización pasa a activa, sale el webhook
mandate.authorizedy el primer cobro sigue la configuración de la suscripción. Sin aprobación, sigue esperando sin gastar reintentos.
pending: se resuelve sola en la liquidación.Códigos del banco
Son los códigos que devuelve el banco en un cobro. pagolink no los muestra tal cual: el cliente y el comercio ven el motivo en palabras (reason o failureReason).
| Código | Significado | Qué pasa |
|---|---|---|
RC, MD01, MD09, MD22 | Afiliación pendiente, o comercio inactivo o suspendido en la lista del cliente. | La autorización sigue pendiente y se pide al cliente que la apruebe en su banco. |
MD15 | El cobro excede el límite del cliente. | Cobro fallido; se reintenta según las reglas del comercio. |
MD21 | El cobro no cumple los parámetros del cliente. | Cobro fallido. |
CUST | El cliente canceló el cobro. | Cobro fallido. |
AM04, A12, 0042 | Fondos insuficientes. | Cobro fallido; se reintenta según las reglas del comercio. |
0004 | Fuera de la franja horaria del banco. | Se reintenta. |
PE | Pendiente de liquidación en el BCV. | No es un rechazo: el cobro sigue en proceso hasta que liquide. |
Reintentos de los cobros automáticos
Cuando un cobro del plan falla, pagolink decide según el motivo. Un rechazo temporal (declineSeverity: "soft": fondos insuficientes, límite excedido, banco fuera de servicio, o un código sin clasificar) se reintenta. Un rechazo definitivo ("hard": cuenta cancelada o bloqueada, número de cuenta incorrecto, datos que no corresponden al titular, domiciliación revocada, suspendida o sin afiliar) no se reintenta: el plan pasa a mora de una vez, o se cancela, según tu política, y el cliente recibe un correo que dice que su banco rechazó el cobro, con el enlace para pagar. Un cobro programado con rechazo definitivo queda failed con willRetry: false.
Política por proyecto, en Ajustes › Suscripciones › Reintentos o por la API de la consola:
GET /v1/settings/billing-retries
PUT /v1/settings/billing-retries # todos los campos son opcionales
{ "maxRetries": 3, "intervalDays": 1, "onExhausted": "past_due", "pastDueDays": 14 }
| Campo | Valores | Qué hace |
|---|---|---|
maxRetries | 0 a 5 (por defecto 3) | Reintentos tras el primer rechazo. 0 no reintenta. |
intervalDays | 1 a 7 (por defecto 1) | Días entre intentos. |
onExhausted | past_due o cancel | Al acabarse los intentos, o ante un rechazo definitivo: dejar en mora con el enlace de pago, o cancelar de inmediato (cancelReason: payment_failure). |
pastDueDays | 1 a 60 (por defecto 14) | Con past_due: días en mora antes de cancelar sola. |
En cada fallo del ciclo sale subscription.charge_failed con attempt y nextAttemptAt; subscription_charge.failed trae los mismos dos campos. Un cobro programado nuevo sin maxAttempts ni retryIntervalHours toma de la política maxRetries + 1 intentos y intervalDays en horas.
Por revisar. Un cobro del plan que el banco no confirma en 72 horas ya no se cierra como fallido: queda por revisar, no sale otro cobro de ese ciclo y llega subscription.charge_review_required. Resuélvelo con POST /v1/subscriptions/{id}/transactions/{txn_…}/review y {"outcome": "paid", "reference": "…"} (la referencia del banco es obligatoria) o {"outcome": "failed"}. Para un cobro programado, POST /v1/subscriptions/{id}/charges/{sch_…}/review.
Ciclo de vida de la suscripción
Cancelar. POST /v1/subscriptions/{id}/cancel con {"atPeriodEnd": true, "reason": "…"} deja el plan activo hasta su próxima fecha de cobro y ahí lo cancela en lugar de cobrar (cancellationReason.type: "scheduled_cancellation"). Se deshace con POST /v1/subscriptions/{id}/resume-cancel. Sin atPeriodEnd cancela ya; un plan completed responde 409 ALREADY_COMPLETED.
Modificar. PATCH /v1/subscriptions/{id} con metadata (se mezcla; null borra la clave), externalId (único por proyecto y entorno; GET /v1/subscriptions?externalId=… filtra) y amount (para los próximos cobros; el ciclo en curso no cambia). Cada cambio emite subscription.updated con previous.
Ciclos. GET /v1/subscriptions/{id}/cycles: una fila por ciclo con periodStart, periodEnd, amount, status (paid, open, under_review, void), attempts, bankReference y paidVia.
Prueba gratuita. trialDays (1 a 365) o startDate (ISO futura) al crear. Mientras tanto el status es trialing o waiting_start: estados derivados de las fechas que no existen en la base. Las suscripciones que nacen de un enlace de pago con prueba o inicio en el plan se muestran siempre como active.
Cobros programados
Un cobro programado es un cargo puntual contra una suscripción con la autorización activa: un extra, una cuota adicional o un cobro en otra fecha. No cambia el plan ni su ciclo, y tiene sus propios reintentos. Se crea con la clave secreta y exige Idempotency-Key (sin ella, 400 IDEMPOTENCY_KEY_REQUIRED; ver Idempotencia). El cobro sale en el barrido del worker y no dentro de la llamada: la respuesta es rápida y no depende del banco.
POST /v1/subscriptions/{id}/charges # id o sub_…; exige Idempotency-Key
GET /v1/subscriptions/{id}/charges # { data: [...] }, los 100 más nuevos
GET /v1/subscriptions/{id}/charges/{chargeId} # sch_…
POST /v1/subscriptions/{id}/charges/{chargeId}/cancel
POST /v1/subscriptions/{id}/charges/{chargeId}/retry # { "scheduledAt"? }
POST /v1/subscriptions/{id}/charges/{chargeId}/mark-paid # { "reference"?, "note"? }
Programar un cobro
| Campo | Tipo | Qué hace |
|---|---|---|
amount | texto o número, mayor que cero, 2 decimales | Monto del cobro. |
currency | USD, EUR o VES | En divisas, el banco recibe bolívares a la tasa BCV del momento de cada intento. |
description | texto, hasta 120 | Concepto del cobro. El banco recibe los primeros 60 caracteres. |
scheduledAt | fecha ISO | Cuándo intentarlo. Por defecto, ahora: en ese caso el cobro sale en el siguiente barrido del worker, no en esta respuesta. No puede estar a más de 5 minutos en el pasado ni a más de 366 días; fuera de eso, 400 INVALID_SCHEDULE. |
maxAttempts | entero, de 1 a 5 | Intentos antes de dar el cobro por fallido. Por defecto, 3. |
retryIntervalHours | entero, de 1 a 168 | Horas entre un intento fallido y el siguiente. Por defecto, 24. |
taxCategory | STANDARD, REDUCED, LUXURY o EXEMPT | IVA del cobro cuando el precio no lo incluye. Por defecto, STANDARD. |
metadata | objeto | Tus referencias, con las reglas de metadata. Vuelven en el webhook del cobro. |
curl https://api.pagolink.co/v1/subscriptions/sub_…/charges \
-H "x-api-key: $PAGOLINK_SK" -H "Content-Type: application/json" \
-H "Idempotency-Key: extra-instalacion-7731" \
-d '{ "amount": "5.00", "currency": "USD", "description": "Instalación extra", "maxAttempts": 3, "retryIntervalHours": 24 }'
# → 201
{ "id": "sch_…", "object": "subscription_charge", "subscriptionId": "sub_…",
"amount": "5.00", "currency": "USD", "description": "Instalación extra",
"status": "scheduled", "scheduledAt": "…", "nextAttemptAt": "…",
"attempts": 0, "maxAttempts": 3, "retryIntervalHours": 24, "failureReason": null,
"transactionId": null, "paidOutside": false, "metadata": null, "livemode": true,
"createdAt": "…", "completedAt": null, "canceledAt": null }
Las comprobaciones salen en este orden: suscripción cancelada (409 SUBSCRIPTION_NOT_CHARGEABLE); autorización que no está active (409 MANDATE_NOT_ACTIVE); suscripción que no está active ni past_due (409 SUBSCRIPTION_NOT_CHARGEABLE).
Estados
scheduled ─► processing ─► completed
▲ │
└── fallo ───┘ si quedan intentos; si no, failed
scheduled ─► canceled (cancel)
failed ─► scheduled (retry, intentos a cero)
scheduled | failed ─► completed (mark-paid, paidOutside: true)
- Barrido. El worker toma hasta 100 cobros con su hora de intento vencida en cada vuelta y los pasa a
processing. Un cobro enprocessingno se toca: cancelarlo, reintentarlo o marcarlo pagado responde409(CHARGE_IN_PROGRESS,CHARGE_NOT_CANCELABLEoCHARGE_NOT_RETRYABLE). - Espera sin gastar intento. Si no hay tasa BCV del día, o si al momento del intento la autorización o la domiciliación no están activas, el cobro vuelve a
scheduleduna hora después y el intento se devuelve. - Fallo. El intento falla por
affiliation_pending(el banco aún no aprueba la domiciliación: la autorización no cambia),bank_declined(rechazo del banco),mandate_incomplete(faltan datos del mandato) obank_returned(la liquidación devolvió el cobro). Si quedan intentos, el cobro vuelve ascheduledconnextAttemptAt= ahora +retryIntervalHours; si no, quedafailed.failureReasonguarda el motivo del último intento. - Éxito.
completedconcompletedAty eltransactionId(txn_…) del intento. Es un cobro real: pasa por la liquidación normal, con comisión, factura y el webhookpayment.completed. Un cobro programado no emitesubscription.charged: su evento essubscription_charge.completed. - Cancelar la suscripción cancela sus cobros
scheduled, con un webhooksubscription_charge.canceledpor cada uno.
Cancelar, reintentar y marcar pagado
POST …/cancelsolo sirve desdescheduled. Devuelve el cobro constatus: "canceled". Desde cualquier otro estado,409 CHARGE_NOT_CANCELABLE.POST …/retrysolo sirve desdefailed. Devuelve el cobro ascheduledconattemptsen cero yfailureReasonennull;scheduledAtynextAttemptAtpasan ascheduledAtde la petición o, si no lo mandas, a ahora. Retry no emite webhook. Desde otro estado,409 CHARGE_NOT_RETRYABLE.POST …/mark-paidregistra un pago recibido fuera de pagolink (efectivo, transferencia directa). Sirve desdescheduledofailedy devuelve el cobro constatus: "completed"ypaidOutside: true. No hay transacción del banco, comisión ni factura.reference(hasta 60) ynote(hasta 200) se guardan en la metadata del cobro. Con el cobro enprocessing,409 CHARGE_IN_PROGRESS; si ya estácompletedocanceled,409 CONFLICT.
Webhooks
| Evento | Cuándo | Qué hacer |
|---|---|---|
subscription_charge.created | Se programó el cobro. data es el cobro, con la misma forma de la respuesta. | Guardar el sch_… con tu registro. |
subscription_charge.completed | Cobrado, o marcado pagado (paidOutside: true). | Cerrar el cargo: es dinero recibido. |
subscription_charge.failed | Falló un intento. data.willRetry dice si habrá otro. | Con willRetry: false, avisar al cliente o marcarlo pagado. |
subscription_charge.canceled | Cancelado a mano o al cancelarse la suscripción. | Quitarlo de tus cobros pendientes. |
Cobrar ahora
POST /v1/subscriptions/{id}/charge-now cobra el cargo del plan en ese momento. No es un cobro programado: cobra solo el cargo base, sin el uso del período, y si falla cuenta como fallo del plan (reintentos y mora). Exige subscriptions.manage.
Cerrar el ciclo del plan
Cuando un cargo del plan no se cobra (la suscripción queda past_due) o tiene reintentos pendientes, el comercio puede cerrarlo a mano. Son dos decisiones distintas: marcar pagado, si el dinero llegó por fuera de pagolink, o incobrable, si decides perdonar el ciclo. Las dos requieren subscriptions.manage, aceptan Idempotency-Key, y responden 409 NOTHING_OWED si la suscripción está al día.
POST /v1/subscriptions/{id}/mark-paid # { "reference"?: "…", "note"?: "…" }
POST /v1/subscriptions/{id}/mark-uncollectible # { "note"?: "…" }
Marcar pagado
- Registra el ciclo adeudado como pagado: una transacción
manualcompletada por el monto y la moneda del ciclo, con el descuento aplicado y sin conversión. No tiene comisión y no pasa por la liquidación: no sale factura. - La suscripción vuelve a
activecon los reintentos en cero. Si venía depast_due, su próximo cobro se recorre un intervalo desde ahora. - Respuesta: la suscripción con la misma forma de su consulta, más
transactionId(txn_…) ypaidOutside: true. - Webhook
subscription.chargedcon la misma suscripción,transactionIdypaidOutside: true. - Si hay un cobro del plan en curso (
processing), responde409 CHARGE_IN_PROGRESS.
Marcar incobrable
- Perdona el ciclo: no se cobra ni entra dinero. Los reintentos quedan en cero, la suscripción vuelve a
activesin mora y, si venía depast_due, su próximo cobro se recorre un intervalo desde ahora. - Respuesta: la suscripción, con la misma forma de su consulta.
- Webhook
subscription.cycle_uncollectible: la suscripción másnote.
Enlace de contingencia
Cuando el cargo automático no pasa, el cliente puede pagar esa deuda por el checkout con otro método: Pago Móvil, transferencia, C2P, Binance u otro. También sirve al cliente cuyo banco no permite domiciliación. Se paga una deuda a la vez, la más antigua primero.
Qué se debe
- El ciclo del plan, si la suscripción está
past_dueoactiveconretryCountmayor que cero. Su monto es el del ciclo con su descuento, en la moneda de la suscripción. Si el último intento fallido guardó uso del período, se suma igual que al ciclo. - Un cobro programado en
failed, o enscheduledcon intentos y unfailureReason. Su monto y su moneda son los del cobro. - No hay deuda mientras un cobro del plan o ese cobro programado esté en
processing: el banco todavía puede cobrarlo.
Ver la deuda: owed
GET /v1/subscriptions/{id} y la consulta pública de la suscripción (GET /v1/sub/{publicId}) traen owed. Es null si la suscripción está al día.
"owed": {
"kind": "cycle", # o "scheduled_charge"
"amount": "10.00",
"currency": "USD",
"description": "Plan mensual"
}
Generar el enlace (comercio)
POST /v1/subscriptions/{id}/recovery-link # exige subscriptions.manage; Idempotency-Key opcional
- Respuesta:
{ "url", "sessionId", "amount", "currency" }. Laurles la del checkout de esa deuda: envíala al cliente por el canal que uses, por ejemplo WhatsApp. - Si ya hay una sesión pendiente de esa misma deuda creada hace menos de 30 minutos, la respuesta reutiliza esa sesión y no crea otra.
- Sin deuda,
409 NOTHING_OWED.
Pagarlo (cliente)
POST /v1/sub/{publicId}/pay # público, sin cuerpo
- Responde con la misma forma que el enlace del comercio:
{ "url", "sessionId", "amount", "currency" }. Límite: 6 peticiones por minuto por sesión y 30 por minuto por IP. - La página
/sub/{publicId}lo usa en el bloque «Cobro pendiente» (botón «Pagar ahora»), que aparece solo si la suscripción está autorizada y tiene deuda. - Sin deuda,
409 NOTHING_OWED.
Qué pasa al pagarlo
- Ciclo del plan. Si la suscripción todavía debe, vuelve a
activeconretryCounten cero. Si venía depast_due, su próximo cobro se recorre un intervalo desde ahora. Sale el webhooksubscription.chargedconpaidVia: "checkout"y eltransactionId. - Cobro programado. Pasa a
completedcon sutransactionIdy la metadata del cobro llevapaidVia: "checkout". Sale el webhooksubscription_charge.completedconpaidVia: "checkout". - Pago de una deuda que ya no existía (el banco la cobró antes): la suscripción no se toca y la transacción lleva
metadata.recoveryOverpaid: true. Decides si la reembolsas. - La transacción del checkout sigue el camino normal: comisión, factura y el webhook
payment.completed. - Sin doble cobro. Mientras la sesión de contingencia está en
processing, el barrido no vuelve a intentar por domiciliación esa misma deuda. - Aviso al cliente. Cuando un cargo automático o un cobro programado falla, el correo agrega: «Puedes pagarlo ahora con otro método» con el enlace de la página de la suscripción.
Probar en modo prueba
En modo prueba, una cuenta del cliente que termina en 1111 rechaza la domiciliación (bank_declined, con el código TEST_DECLINED), igual que la tarjeta 1111 en el checkout. Así se reproduce el fallo sin banco. Las demás terminaciones fijan el resultado de la suscripción:
| Cuenta termina en | Resultado |
|---|---|
1111 | Rechazo temporal: se reintenta según tu política de reintentos. |
2222 | Rechazo definitivo (cuenta cancelada): no se reintenta. |
3333 | Queda en proceso y se liquida solo al minuto. |
4444 | El banco nunca responde: pasa a «por revisar» a los 5 minutos (en vivo, a las 72 horas). |
| otra | Se aprueba al instante. |
Para aprobar o rechazar una domiciliación que espera al banco, POST /v1/test_helpers/subscriptions/{id}/mandate con {"resolution": "approve"} o "decline". Solo existe en modo prueba: con una suscripción en vivo responde 404. El cobro de verificación no cambia: sigue simulando una autorización aprobada.
Facturación por uso
Un precio recurrente puede cobrar un cargo base fijo en cada ciclo más lo que el cliente usó en el período: lecturas, minutos, unidades. Lo usado se envía como eventos a un medidor. Al cerrar el ciclo, pagolink suma los eventos del período y cobra el total según el esquema del precio.
Son tres pasos: crear el medidor (qué se cuenta), crear el precio con su esquema (cuánto cuesta cada unidad) y mandar los eventos a la suscripción (cuánto usó).
Medidores
POST /v1/meters # { name, eventName, aggregation } → 201 medidor
GET /v1/meters # → { data: [ medidor, … ] }
GET /v1/meters/{id} # mtr_…
PATCH /v1/meters/{id} # { name?, active? } → medidor
| Campo | Tipo | Qué es |
|---|---|---|
name | texto, hasta 80 | Nombre para ti. El cliente no lo ve. |
eventName | minúsculas, números, _ . -; de 1 a 64 | Identifica el medidor en los eventos. Única por negocio y entorno: si ya existe, 409 METER_EVENT_NAME_TAKEN. |
aggregation | sum, count o last | sum (por defecto) suma los value; count cuenta eventos sin mirar su valor; last toma el value del evento más reciente del período. |
active | booleano | Un medidor inactivo no recibe eventos (409 METER_INACTIVE) y no se puede usar en un precio nuevo (404 METER_NOT_FOUND). Desactivarlo no borra su historial. |
curl https://api.pagolink.co/v1/meters -H "x-api-key: $PAGOLINK_SK" -H "Content-Type: application/json" \
-d '{ "name": "Lecturas de API", "eventName": "api_lecturas", "aggregation": "sum" }'
# → 201 { "id": "mtr_…", "object": "meter", "name": "Lecturas de API", "eventName": "api_lecturas",
# "aggregation": "sum", "active": true, "livemode": true, "createdAt": "…" }
Leer medidores requiere catalog.read; crearlos y cambiarlos, catalog.manage.
Eventos de uso
POST /v1/meters/events
{ "eventName": "api_lecturas", "subscriptionId": "sub_…", "value": 2,
"timestamp": "2026-10-20T14:03:00Z", "identifier": "lectura-9f3a" }
# → 201 { "id": "3f9c…-uuid", "object": "meter_event", "meter": "mtr_…", "subscriptionId": "sub_…", "value": "2",
# "timestamp": "…", "identifier": "lectura-9f3a", "duplicate": false }
# → 200 la misma petición otra vez: el evento que ya existía, con "duplicate": true
identifierdeduplica. Un reintento con el mismo identificador en el mismo medidor responde200con el evento que ya existía. Si la misma clave apunta a otra suscripción,409 CONFLICT. Si no lo mandas, pagolink pone uno aleatorio y un reintento contaría dos veces: manda tu propio identificador, por ejemplo el id de tu registro (hasta 255 caracteres).iddel evento es un uuid.valuevuelve como texto; por defecto, 1.timestampes el momento del uso. Tiene que estar después del inicio del período en curso (currentPeriodStart; si la suscripción todavía no tiene período, no más de 35 días atrás) y no más de 5 minutos en el futuro. Fuera de esa ventana,400 USAGE_EVENT_OUT_OF_RANGE.- Errores:
404 METER_NOT_FOUNDsi eleventNameno existe;409 METER_INACTIVE;422 SUBSCRIPTION_NOT_METEREDsi la suscripción no tiene un precio por uso de ese medidor.
Esquemas de precio
El precio declara billingScheme. flat es el precio de siempre: solo el cargo base. Los otros tres cobran el uso:
billingScheme | Cómo cobra el uso | Campos |
|---|---|---|
flat | No cobra uso. Es el valor por defecto. Su monto tiene que ser mayor que cero. | — |
per_unit | Cada unidad usada, al mismo precio. | usageUnitAmount, texto con hasta 6 decimales. |
tiered | Por tramos: cada tramo cobra solo las unidades que caen en él. | tiers |
volume | Un solo precio para todo el uso, el del tramo donde cae el total. | tiers |
unitAmount sigue siendo el cargo base fijo del ciclo y en un precio por uso puede ser 0.00. Cada tramo tiene upTo, unitAmount y flatAmount (cargo fijo del tramo, que se cobra solo si se usó alguna unidad de él). El último tramo tiene upTo: null. upTo incluye su propio límite: con upTo: 10, la unidad número 10 es del primer tramo y la 11 ya es del siguiente.
# per_unit: usageUnitAmount "0.50"; uso del período = 4
# 4 × 0.50 = 2.00
# tiered: [ { upTo: 10, unitAmount: "1.00", flatAmount: "0.00" },
# { upTo: null, unitAmount: "0.50", flatAmount: "5.00" } ] uso del período = 12
# tramo 1: 10 unidades (1 a 10) × 1.00 = 10.00
# tramo 2: 2 unidades (11 y 12) × 0.50 = 1.00, más el fijo del tramo 5.00 = 6.00
# uso = 16.00
# volume: los mismos tramos; el total 12 cae en el tramo 2, así que todo a 0.50
# 12 × 0.50 + 5.00 = 11.00 (uso 10 cae en el tramo 1: 10 × 1.00 = 10.00)
# flat: el uso no cobra nada
Reglas de los tramos: de 1 a 10 tramos; upTo es un entero mayor que cero y estrictamente creciente; el último es null; unitAmount y flatAmount son textos decimales mayores o iguales que cero. Si algo no cuadra, 400 INVALID_TIERS con el motivo.
Al crear un precio (POST /v1/products/{id}/prices) se agregan billingScheme, meterId (mtr_…), usageUnitAmount y tiers:
- Un esquema distinto de
flatexigetype: "recurring"y un medidor activo del mismo entorno. Si el medidor no existe o está desactivado,404 METER_NOT_FOUND. per_unitexigeusageUnitAmounty no admitetiers.tieredyvolumeexigentiersy no admitenusageUnitAmount.flatno admite ninguno de los tres.- Si no cuadra,
400 INVALID_BILLING_SCHEME. Las respuestas de precio traenbillingScheme,meter(mtr_…onull),usageUnitAmountytiers.
Cómo se cobra en el ciclo
- Base más uso. En cada ciclo el monto del cobro es el cargo base de la suscripción más el uso del período que se cierra: los eventos cuya fecha cae entre el inicio del período (incluido) y el próximo cobro (excluido).
- Descuento sobre el total. Si la suscripción tiene un descuento, se aplica sobre la suma (base más uso).
- Total en cero. Si el total es cero (un plan solo por uso sin consumo), no se cobra nada: el ciclo avanza y la suscripción sigue activa, sin reintentos.
- Conversión. La suma va en la moneda del precio y se convierte a bolívares a la tasa BCV del cobro, igual que cualquier precio en divisas.
- Reintento. Si el cobro falló y se reintenta, cobra el mismo uso del intento fallido: los eventos que llegaron después son del período nuevo y no se cobran dos veces.
- Registro. La transacción guarda el uso en
metadata.usage(medidor, cantidad, monto y moneda) y el período enmetadata.period(startyend).
GET /v1/subscriptions/{id}/usage muestra el uso del período en curso sin esperar al cobro. Su periodStart es el inicio del período y su periodEnd el próximo cobro:
GET /v1/subscriptions/sub_…/usage
{ "object": "usage_summary", "subscriptionId": "sub_…", "meter": "mtr_…",
"periodStart": "…", "periodEnd": "…", "aggregation": "sum",
"quantity": "4", "amount": "2.00", "currency": "USD", "billingScheme": "per_unit" }
Sobre una suscripción de precio fijo responde 422 SUBSCRIPTION_NOT_METERED. Leerlo requiere subscriptions.read; mandar eventos, subscriptions.manage.
Envíos de pago móvil
Un envío paga bolívares desde tu cuenta de cobro al pago móvil de un beneficiario (proveedores, devoluciones, comisiones). Se crea con la clave secreta y exige Idempotency-Key (sin ella, 400 IDEMPOTENCY_KEY_REQUIRED; ver Idempotencia): repetir la misma clave devuelve el mismo envío y no vuelve a pagar. En vivo sale por el banco activo del comercio (Banco Plaza, BNC o R4) y el banco aplica sus topes; con sk_test se simula y termina completed con un providerRef que empieza por SIM-.
POST /v1/payouts # exige Idempotency-Key; permiso payouts.create
GET /v1/payouts # { data, page, limit, total }; ?status= &page= &limit= (hasta 100); permiso payouts.read
GET /v1/payouts/{id} # el id (uuid) o el publicId (po_…); permiso payouts.read
Crear un envío
| Campo | Tipo | Qué hace |
|---|---|---|
amount | texto decimal, 2 decimales como máximo | Monto en bolívares. La moneda siempre es VES. |
beneficiaryBank | 4 dígitos | Código del banco del beneficiario (0191 para BNC). Ver Bancos. |
beneficiaryId | cédula o RIF | V12345678, J000121532; letras V, E, J, G o P, con o sin guion. |
beneficiaryName | texto, de 2 a 120 | Nombre del beneficiario. |
beneficiaryPhone | de 10 a 15 dígitos | Teléfono móvil con código de área: 04142345678 o 584142345678. |
reason | texto, hasta 120 | Concepto del envío. |
curl https://api.pagolink.co/v1/payouts \
-H "x-api-key: $PAGOLINK_SK" -H "Content-Type: application/json" \
-H "Idempotency-Key: pago-proveedor-5521" \
-d '{ "amount": "1.00", "beneficiaryBank": "0191", "beneficiaryId": "V23000760", "beneficiaryName": "Julian Esteves", "beneficiaryPhone": "04242207524", "reason": "Pago de prueba" }'
# → 201
{ "id": "5fa7965d-…", "publicId": "po_FoCgnkj5o00SvDbF", "environment": "live", "status": "completed",
"amount": "1.00", "currency": "VES", "beneficiaryBank": "0191", "beneficiaryId": "V23000760",
"beneficiaryName": "Julian Esteves", "beneficiaryPhone": "04242207524", "reason": "Pago de prueba",
"providerRef": "59618", "failureReason": null, "createdAt": "…", "completedAt": "…" }
status es processing (el banco no dio respuesta firme; se resuelve solo y se avisa por webhook), completed o failed (con el motivo en failureReason). Los webhooks son payout.completed y payout.failed y traen el envío en data. Sin banco activo en vivo, 409 PAYOUT_BANK_INACTIVE.
Bancos
Catálogo público de los bancos de Venezuela con su código de cuatro dígitos. Sirve para llenar listas en tu formulario, por ejemplo el banco del cliente. No pide clave y responde con Cache-Control: public, max-age=3600.
GET /v1/banks
# → { "data": [ { "code": "0102", "name": "Banco de Venezuela" }, { "code": "0104", "name": "Venezolano de Crédito" }, … ] }
GET /v1/banks?method=direct_debit
# → { "data": [ { "code": "0102", "name": "Banco de Venezuela" }, { "code": "0138", "name": "Banco Plaza" }, … ] } los bancos que acepta la domiciliación: sin restricción, el catálogo completo
GET /v1/banks?method=otro
# → 400 { "statusCode": 400, "code": "INVALID_FILTER", "retryable": false, "message": "method admite direct_debit" }
Con method=direct_debit la lista sale de la regla del banco que cobra. Hoy la domiciliación de Banco Plaza es interbancaria y ningún banco restringe, así que la respuesta trae el catálogo completo; si un banco restringe sus cuentas, la lista se reduce a los que acepta.
Idempotencia
Si una petición se repite por un corte de red o un reintento de tu servidor, no debe cobrar ni crear algo dos veces. Manda la cabecera Idempotency-Key con un valor único por operación de tu negocio (un UUID o el id de tu pedido). pagolink guarda la respuesta: el reintento recibe la misma y nada se ejecuta de nuevo.
curl https://api.pagolink.co/v1/checkout/sessions \
-H "x-api-key: $PAGOLINK_SK" -H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-12487-cobro-1" \
-d '{ "amount": "1250.00", "currency": "VES" }'
Aceptan la cabecera estas rutas de escritura: POST /v1/checkout/sessions, POST /v1/payment-links, POST /v1/subscriptions, POST /v1/subscriptions/{id}/charge-now, POST /v1/subscriptions/{id}/mark-paid, POST /v1/subscriptions/{id}/mark-uncollectible, y las de cobros programados: POST …/charges (aquí la clave es obligatoria), …/cancel, …/retry y …/mark-paid. Las facturas tienen su propia regla, con la misma clave: ver Facturación.
| Caso | Respuesta |
|---|---|
| Primera vez | Se ejecuta y la respuesta se guarda. |
| Misma clave, misma petición, ya terminó | La misma respuesta, con el mismo estado y cuerpo, y la cabecera Idempotent-Replayed: true. No se ejecuta. |
| Misma clave, otro cuerpo u otra ruta | 409 IDEMPOTENCY_KEY_REUSED. |
| Misma clave, todavía en curso | 409 IDEMPOTENCY_REQUEST_IN_PROGRESS. Espera unos segundos y repite con la misma clave. |
| Clave no válida | 400 INVALID_IDEMPOTENCY_KEY. Son de 1 a 255 caracteres visibles ASCII, sin espacios. |
| Falta en una ruta que la exige | 400 IDEMPOTENCY_KEY_REQUIRED. Hoy solo la exige POST …/charges. |
- Alcance. La clave vale por negocio y por entorno: una clave de prueba no choca con una de vivo. Sin clave de API, el entorno sale del query
environmenty, si tampoco viene, es vivo. - Vencimiento. Dura 24 horas. Después, la misma clave cuenta como nueva.
- Solo se guardan las respuestas exitosas. Si la petición falla (400, 409 o 5xx), no queda nada: corrige y repite con la misma clave.
- Huella de la petición. Se calcula con el método, la ruta y el cuerpo. Por eso una clave usada en otra ruta responde
IDEMPOTENCY_KEY_REUSED. - Desde un navegador, la cabecera
Idempotent-Replayedse puede leer.
Errores
Todo error de la API responde JSON con la misma forma. Las claves que ya existían no cambian (statusCode, message y las propias de cada error, como field, permission, attemptsLeft o allowedBanks); se agregan code y retryable:
{ "statusCode": 400, "code": "VALIDATION_FAILED", "retryable": false,
"message": [ "amount va como texto con dos decimales, por ejemplo \"25.00\": de 0.01 a 999999999.99" ], "details": { "fields": [ "amount" ] } }
Un JSON roto responde code: "INVALID_JSON", y un campo que no existe, VALIDATION_FAILED con ese campo en details.fields.
codees lo que compara tu código. El texto demessageestá en español y puede cambiar.retryabledice si reintentar la misma petición, sin cambiarla, puede funcionar: saturación, un servicio externo caído, un límite de peticiones. Si esfalse, hay que corregir algo antes de repetir.details.fieldsaparece solo enVALIDATION_FAILED. Ahímessagees una lista de frases, una por campo que falló, yfieldstrae el nombre de cada campo.INTERNAL_ERRORresponde un mensaje genérico, sin trazas ni datos internos. Reintenta más tarde; si persiste, escribe a soporte con la hora de la petición.
Las tablas siguientes salen del catálogo de códigos del core. Cuando un código aparece en otra sección de esta página, esa sección explica el caso con más detalle.
Generales
| Código | HTTP | Reintentable | Qué hacer |
|---|---|---|---|
INVALID_REQUEST | 400 | no | La petición no es válida. Revisa el cuerpo y los parámetros. |
UNAUTHORIZED | 401 | no | Falta la clave de API o no es válida. Revisa x-api-key o inicia sesión de nuevo. |
FORBIDDEN | 403 | no | Tu usuario o tu clave no tiene permiso para esta acción. |
NOT_FOUND | 404 | no | La ruta o el recurso no existe en este comercio y entorno. Revisa el id y el entorno. |
CONFLICT | 409 | no | La petición choca con el estado actual del recurso. Consúltalo y decide cómo seguir. |
UNPROCESSABLE | 422 | no | La petición tiene la forma correcta, pero no se puede procesar con los datos actuales. |
RATE_LIMITED | 429 | sí | Hiciste demasiadas peticiones seguidas. Espera un momento y reintenta. |
INTERNAL_ERROR | 500 | sí | Ocurrió un error interno en pagolink. Reintenta; si sigue, avisa a soporte con la hora de la petición. |
SERVICE_UNAVAILABLE | 503 | sí | El servicio no está disponible en este momento. Reintenta en unos minutos. |
VALIDATION_FAILED | 400 | no | Uno o más campos no cumplen las reglas. La lista de campos está en details.fields. |
INVALID_JSON | 400 | no | El cuerpo de la petición no es un JSON válido. |
Idempotencia
| Código | HTTP | Reintentable | Qué hacer |
|---|---|---|---|
IDEMPOTENCY_KEY_REQUIRED | 400 | no | Esta petición exige la cabecera Idempotency-Key. Envía una clave única por operación. |
INVALID_IDEMPOTENCY_KEY | 400 | no | La Idempotency-Key no es válida: de 1 a 255 caracteres visibles, sin espacios. |
IDEMPOTENCY_KEY_REUSED | 409 | no | Esa Idempotency-Key ya se usó con otra petición. Usa una clave nueva para la nueva operación. |
IDEMPOTENCY_REQUEST_IN_PROGRESS | 409 | sí | La petición con esa Idempotency-Key todavía se está procesando. Espera y reintenta con la misma clave. |
Cobros, enlaces y sesiones
| Código | HTTP | Reintentable | Qué hacer |
|---|---|---|---|
BANK_NOT_ACTIVE | 409 | no | Las suscripciones se cobran por domiciliación y tu banco no está activo. Complétalo en Cuenta de cobro. |
DOMICILIATION_NOT_AVAILABLE | 409 | no | Tu banco está activo pero la domiciliación no está disponible: está apagada en Métodos o el banco no tiene bancos de pagadores habilitados para domiciliar. El mensaje dice la causa. |
LINK_ALREADY_PAID | 409 | no | Este enlace ya se pagó. Crea un enlace nuevo para otro cobro. |
LINK_SOLD_OUT | 409 | no | Este enlace ya no está disponible: se vendió lo que ofrecía el comercio. |
LINK_IS_SUBSCRIPTION | 400 | no | Este enlace es de una suscripción: el cliente debe suscribirse, no pagar una vez. |
LINK_IS_NOT_SUBSCRIPTION | 400 | no | Este enlace es de un pago, no de una suscripción. |
NO_DEFAULT_PRICE | 409 | no | El producto no tiene un precio predeterminado activo. Asígnale uno o elige el precio del enlace. |
NO_METHODS_AVAILABLE | 409 | no | No hay ningún método de cobro activo. Activa al menos uno en Métodos. |
METHOD_NEEDS_CONNECTION | 409 | no | Este método necesita la conexión con el banco. Complétala en Cuenta de cobro. |
METHOD_NOT_AVAILABLE | 400 | no | Ese método no está disponible para este pago. Elige otro. |
SESSION_ALREADY_ATTEMPTED | 409 | no | Ya se intentó pagar este cobro, así que el monto no se puede cambiar. Crea una sesión nueva. |
TERMS_NOT_ACCEPTED | 400 | no | El pagador tiene que aceptar los términos para continuar. |
CUSTOM_FIELD_REQUIRED | 400 | no | Falta un dato que el comercio pide para el cobro. Revisa field. |
CUSTOM_FIELD_INVALID | 400 | no | Un dato que pide el comercio no tiene el formato esperado. Revisa field. |
PAYER_FIELD_REQUIRED | 400 | no | Falta un dato del pagador que el cobro exige. Revisa field. |
PAYER_FIELD_INVALID | 400 | no | Un dato del pagador no tiene el formato esperado. Revisa field. |
LINE_ITEMS_COUNT | 400 | no | Un cobro lleva entre 1 y el máximo de productos permitido. |
UNKNOWN_LINE_ITEM | 400 | no | Un precio indicado no está en este enlace. |
UNKNOWN_PRODUCT | 400 | no | Algún producto no existe en este entorno. Revisa los ids. |
PRICE_NOT_IN_PRODUCT | 400 | no | El precio no es un precio activo de este producto. |
PRICE_NOT_ONE_TIME | 400 | no | El precio es recurrente: en un cobro solo van precios de pago único. |
PRICE_ENVIRONMENT_MISMATCH | 400 | no | El precio es de otro entorno (prueba o vivo) que el cobro. Usa un precio del mismo entorno. |
DUPLICATE_PRICE | 400 | no | Un precio está repetido en el cobro. Indica la cantidad en una sola línea. |
ITEMS_MIXED_CURRENCY | 400 | no | Los productos del cobro tienen precio en monedas distintas. Usa productos de una sola moneda. |
QUANTITY_OUT_OF_RANGE | 400 | no | La cantidad de un producto está fuera del rango permitido. |
SUBSCRIPTIONS_NOT_AVAILABLE | 409 | no | Este comercio todavía no puede cobrar suscripciones. Revisa la configuración de su cuenta. |
DOMICILIACION_BANK_NOT_ALLOWED | 400 | no | Esta domiciliación solo acepta cuentas de los bancos que indica allowedBanks. |
EMAIL_INVALID | 400 | no | El correo no tiene un formato válido. |
INVALID_CODE | 400 | no | El código no es válido. Pide uno nuevo si ya venció. |
TOO_MANY_ATTEMPTS | 429 | no | Se agotaron los intentos del código. Pide un código nuevo. |
PUBLIC_KEY_REQUIRED | 400 | no | Esta ruta pide la clave publicable (pk_) del comercio. |
TRANSACTION_NOT_FOUND | 404 | no | No existe ese cobro en este entorno. Revisa el id y el entorno. |
Descuentos y cupones
| Código | HTTP | Reintentable | Qué hacer |
|---|---|---|---|
COUPON_NOT_FOUND | 400 | no | Ese cupón no existe en este entorno. |
COUPON_VALUE | 400 | no | El valor del cupón no es válido: indica percentOff o amountOff, uno de los dos. |
COUPON_DURATION | 400 | no | La duración del cupón no es válida: durationInMonths solo va con duration repeating. |
COUPON_INACTIVE | 400 | no | Este descuento ya no está disponible. |
COUPON_EXPIRED | 400 | no | Este descuento venció. |
COUPON_MAX_REDEMPTIONS | 400 / 409 | no | Este descuento ya se usó todas las veces permitidas. Quítalo y cobra sin él. |
COUPON_NOT_APPLICABLE | 400 | no | Este descuento no aplica a los productos de esta compra. |
COUPON_CURRENCY_MISMATCH | 400 | no | La moneda del descuento no es la de la compra. |
DISCOUNT_REF | 400 | no | Indica coupon o promotionCode, uno de los dos. |
DISCOUNT_COVERS_TOTAL | 400 | no | El descuento cubre toda la compra: este cobro no se puede hacer por pagolink. |
DISCOUNT_NOT_SUPPORTED | 400 | no | Un cobro con tasa o montos propios no admite descuentos. |
DISCOUNTS_COUNT | 400 | no | Un cobro o una suscripción admite un solo descuento. |
MINIMUM_AMOUNT_NOT_MET | 400 | no | La compra no llega al monto mínimo que pide este código. |
PROMOTION_CODES_NOT_ALLOWED | 400 | no | Este cobro o enlace no acepta códigos de descuento. |
PROMOTION_CODE_INVALID | 400 | no | Ese código no es válido. |
PROMOTION_CODE_INACTIVE | 400 | no | Este código ya no está disponible. |
PROMOTION_CODE_EXPIRED | 400 | no | Este código venció. |
PROMOTION_CODE_MAX_REDEMPTIONS | 400 / 409 | no | Este código ya se usó todas las veces permitidas. Quítalo y cobra sin él. |
PROMOTION_CODE_MINIMUM | 400 | no | La compra mínima necesita minimumAmountCurrency. |
PROMOTION_CODE_TAKEN | 409 | no | Ya hay un código activo igual en este entorno. Usa otro nombre. |
Catálogo
| Código | HTTP | Reintentable | Qué hacer |
|---|---|---|---|
UNKNOWN_CATEGORY | 400 | no | Esa categoría no existe en este entorno. |
CATEGORY_NAME_TAKEN | 409 | no | Ya hay una categoría con ese nombre. |
CATEGORY_NOT_EMPTY | 409 | no | La categoría tiene productos. Muévelos antes de borrarla. |
Métodos de pago externos
| Código | HTTP | Reintentable | Qué hacer |
|---|---|---|---|
BINANCE_NEEDS_CREDENTIALS | 400 | no | Binance Pay no está configurado para este comercio. Carga su API key y su API secret. |
BINANCE_CREDENTIALS_MALFORMED | 400 | no | La API key o el API secret de Binance están incompletos. Cópialos completos desde Binance Pay. |
BINANCE_CREDENTIALS_INVALID | 400 | no | Binance no aceptó la API key y el API secret. Revisa que sean de Binance Pay (Merchant). |
BINANCE_ORDER_REJECTED | 400 | no | Binance Pay no aceptó este cobro. Paga con otro método. |
BINANCE_UNAVAILABLE | 503 | sí | Binance Pay no respondió. Reintenta en unos minutos o usa otro método. |
CASHEA_INCOMPLETE | 400 | no | Para encender Cashea hacen falta la API key y la caja. |
CASHEA_KEY_INVALID | 400 | no | Esa no parece una API key de Cashea. |
CASHEA_POS_UNKNOWN | 400 | no | Esa caja no está en la cuenta de Cashea. |
CASHEA_QR_INVALID | 400 | no | Ese no parece el QR de una caja de Cashea. |
CASHEA_USD_ONLY | 400 | no | Cashea solo está disponible para cobros en dólares. |
CASHEA_REJECTED | 400 | no | Cashea rechazó la operación. El mensaje indica el motivo que dio. |
CASHEA_UNAVAILABLE | 503 | sí | Cashea no respondió. Reintenta en unos minutos. |
Facturación fiscal
| Código | HTTP | Reintentable | Qué hacer |
|---|---|---|---|
INVOICING_DISABLED | 409 | no | El comercio no tiene la facturación activada. Actívala en Ajustes con sus datos fiscales. |
ISSUER_INCOMPLETE | 409 | no | Faltan datos del emisor para facturar. El mensaje indica cuáles. |
ISSUER_NOT_READY | 409 | no | La imprenta digital del comercio todavía no está lista. Carga sus credenciales o pide el alta. |
TAX_SERVICE_UNAVAILABLE | 503 | sí | La facturación no está disponible en este entorno en este momento. |
TAX_SERVICE_ERROR | 502 | sí | El servicio fiscal no respondió. Reintenta la misma petición en unos minutos. |
FX_RATE_UNAVAILABLE | 409 | sí | No hay tasa BCV publicada para esa moneda. Indica fxRate o reintenta cuando se publique. |
INVOICE_NOT_FOUND | 404 | no | No existe esa factura o el enlace venció. |
INVOICE_NOT_ISSUED | 409 | no | La factura todavía no está emitida. Espera a que la imprenta la emita. |
INVOICE_ALREADY_VOIDED | 409 | no | La factura ya está anulada. |
PDF_UNAVAILABLE | 409 | no | La factura todavía no tiene número: no hay documento que descargar. |
NUMBERING_UNAVAILABLE | 422 | no | La imprenta no pudo asignar el número de la factura. Revisa el rango de numeración. |
CREDIT_NOTE_REJECTED | 422 | no | La imprenta rechazó la nota de crédito. El mensaje indica qué corregir. |
VOID_REJECTED | 422 | no | La imprenta rechazó la anulación. El mensaje indica qué corregir. |
CUSTOMER_EMAIL_REQUIRED | 400 | no | La factura no tiene correo del cliente. Indica email. |
INVALID_TAX_ID | 400 | no | El RIF o la cédula del cliente no es válido: letra V, E, J, P o G y el número. |
INVALID_FILTER | 400 | no | Un filtro de la consulta no es válido. El mensaje indica cuál y cómo escribirlo. |
INVALID_CREDIT_NOTE_LINE | 400 | no | La línea de la nota de crédito no existe en la factura o supera lo facturado. |
INVALID_AMOUNT | 400 | no | Un monto de la factura no es válido. Usa solo dígitos y punto decimal. |
INVALID_QUANTITY | 400 | no | La cantidad de una línea tiene que ser mayor que cero. |
INVALID_DISCOUNT | 400 | no | El descuento de una línea supera el monto de la línea. |
NO_LINES | 400 | no | La factura necesita al menos una línea. |
UNKNOWN_TAX_CATEGORY | 400 | no | La categoría fiscal de una línea no existe. Usa una de las que indica el mensaje. |
UNKNOWN_PAYMENT_METHOD | 400 | no | Un método de pago de la factura no existe. Usa uno de los que indica el mensaje. |
PAYMENTS_REQUIRED | 400 | no | Indica cómo pagó el cliente: en una factura en divisas el IGTF depende de eso. |
PAYMENT_METHOD_CURRENCY | 400 | no | Un método de pago no corresponde a la moneda del pago. |
PAYMENT_MISMATCH | 400 | no | Lo pagado no cuadra con el total de la factura. Revisa los montos de los pagos. |
INVALID_INVOICE | 400 | no | La factura no es válida. El mensaje indica qué revisar. |
INVALID_SCHEDULE | 400 | no | La fecha programada no es válida: no puede estar en el pasado ni a más de un año. |
Cuenta del comercio y permisos
| Código | HTTP | Reintentable | Qué hacer |
|---|---|---|---|
PERMISSION_DENIED | 403 | no | No tienes permiso para esa acción. Pídeselo a quien administra el equipo. |
PERMISSION_NOT_DECLARED | 403 | no | Esa acción no está disponible desde la consola. |
Suscripciones y cobros programados
| Código | HTTP | Reintentable | Qué hacer |
|---|---|---|---|
SUBSCRIPTION_NOT_CHARGEABLE | 409 | no | La suscripción no se puede cobrar en su estado actual: tiene que estar activa o con cobro vencido. |
MANDATE_NOT_ACTIVE | 409 | no | El cliente todavía no autorizó la domiciliación. Espera a que la apruebe en su banco. |
CHARGE_NOT_FOUND | 404 | no | No existe ese cobro programado en esta suscripción. |
CHARGE_NOT_CANCELABLE | 409 | no | Solo se cancela un cobro programado que todavía no se intentó. |
CHARGE_NOT_RETRYABLE | 409 | no | Solo se reintenta un cobro que falló. |
CHARGE_IN_PROGRESS | 409 | no | El cobro del plan se está procesando en el banco. Espera a que termine antes de marcarlo. |
NOTHING_OWED | 409 | no | La suscripción está al día: no hay ningún cobro adeudado que marcar. |
SUBSCRIPTION_NOT_METERED | 422 | no | La suscripción no tiene un precio por uso: no recibe eventos de medición ni tiene resumen de uso. |
Medidores y facturación por uso
| Código | HTTP | Reintentable | Qué hacer |
|---|---|---|---|
METER_NOT_FOUND | 404 | no | No existe ese medidor en este entorno. |
METER_INACTIVE | 409 | no | El medidor está desactivado. Actívalo para registrar eventos. |
METER_EVENT_NAME_TAKEN | 409 | no | Ya hay un medidor con ese nombre de evento en este entorno. |
INVALID_BILLING_SCHEME | 400 | no | El esquema de cobro del precio no es válido para los campos que enviaste. |
INVALID_TIERS | 400 | no | Los tramos del precio no son válidos. El mensaje indica el motivo. |
USAGE_EVENT_OUT_OF_RANGE | 400 | no | La marca de tiempo del evento está fuera de la ventana permitida: ni más de 5 minutos en el futuro ni antes del período en curso. |
Cuentas y equipo (API de cuentas)
| Código | HTTP | Reintentable | Qué hacer |
|---|---|---|---|
ROLE_IN_USE | 409 | no | El rol todavía tiene miembros asignados. Reasigna antes a quienes lo tienen. |
NOT_READY | 422 | no | La cuenta de cobro todavía no tiene los cinco documentos o datos que pide el banco. El campo missing dice cuáles. |
Glosario
Los términos de la domiciliación tienen el mismo nombre en la consola, en esta documentación y en la API.
| Término | Qué significa | En la API |
|---|---|---|
| Domiciliación | El cobro automático de una suscripción desde la cuenta del cliente. | domiciliacion (método); direct_debit en GET /v1/banks |
| Autorización de domiciliación | El permiso que el cliente da para los cobros automáticos. En la consola se llama «Autorización». | mandate, con mandateStatus |
| Número de contrato | El identificador de la autorización ante el banco. Empieza con PL. | contractNumber, con contractDate |
| Aprobar en el banco | Lo que hace el cliente en la banca en línea de su banco para que la domiciliación quede aprobada. | affiliation (affiliated cuando ya está aprobada) |
| Cobro de verificación | El cobro de Bs 1,00 que activa la solicitud en la banca en línea del cliente. No es parte del plan. | Trigger affiliation_probe; affiliationProbe en la consulta de la suscripción |
| Comercio | Quien cobra. Es el «acreedor» solo cuando se explica un término del banco. | — |
| Cliente | Quien paga la suscripción y da la autorización. Es el «deudor» solo cuando se explica un término del banco. | — |
| Enlace de contingencia | El enlace de pago para una deuda de la suscripción que no se cobró por domiciliación. | POST /v1/subscriptions/{id}/recovery-link; ver Enlace de contingencia |
| Débito Inmediato, Pago Móvil, C2P, Transferencia | Métodos de cobro con el banco. Se nombran igual en todas partes. | immediate_debit, pago_movil, c2p, transfer |
| Envíos | Pagos salientes que hace el comercio desde su cuenta. No son cobros. | — |
Mensajes que ve el cliente
- «Cobro de verificación de Bs 1,00»
- «Pendiente de aprobación en tu banco»
- «Apruébala en la banca en línea de tu banco. La solicitud puede tardar hasta el siguiente día hábil en aparecer.»
Probar webhooks
Antes de cerrar algo con un webhook, comprueba que tu servidor lo recibe, lo verifica y responde 2xx. Hay tres formas, de la más rápida a la más real.
1. Evento de prueba desde la consola
En la pantalla de Webhooks de la consola, el botón Enviar prueba de cada endpoint manda un evento webhook.test a esa URL y la consola dice si respondió con 2xx. La prueba no queda en el historial de entregas.
El cuerpo es { "event": "webhook.test", "data": { "message": "…", "at": "…" } }: no trae id ni created, y la firma va solo en X-Payment-Signature. Sirve para comprobar la ruta y el secreto; para la lógica de cierre, usa el punto 2.
2. Un pago de prueba real
Con sk_test_… cobras sin dinero de por medio. Un cobro aprobado (cualquier dato de pagador distinto de 1111 y 2222) dispara payment.completed; el 1111 dispara payment.failed con TEST_DECLINED. Llegan con livemode: false, el mismo id en los reintentos y la misma firma que en vivo.
3. Tu servidor local con un túnel
pagolink no envía a direcciones internas, así que localhost no sirve como URL. Un túnel te da una URL pública https que apunta a tu puerto:
# Cloudflare Tunnel: una URL temporal, sin cuenta
cloudflared tunnel --url http://localhost:3000
# ngrok
ngrok http 3000
Registra en la consola la URL del túnel con la ruta de tu webhook (por ejemplo https://<nombre>.trycloudflare.com/hooks/pagolink) y su secreto. Si quieres ver el cuerpo exacto sin escribir código, un servicio como webhook.site sirve de destino temporal.
Lista de comprobación
- Responde
2xxantes de procesar. Si tardas, pagolink reintenta y el mismoidllega dos veces. - Verifica la firma sobre el cuerpo crudo con
X-Pagolink-Signature(ver Verificar la firma). Cualquier respuesta distinta de2xxcuenta como fallo y se reintenta. - Deduplica por
idy responde2xxtambién al repetido. - Revisa la lista de entregas en la consola: cada intento, su código HTTP y la espera hasta el siguiente.