pagolink
Documentación de integración

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.

El global JavaScript del SDK se expone como window.pagolink.
Para IA, agentes
Toda la doc en un llms.txt
La referencia de integración completa en Markdown plano, lista para el contexto de tu asistente.
→

Dominios

ServicioDominio
Sitiopagolink.co
Consolaapp.pagolink.co
Checkoutpay.pagolink.co
Documentacióndocs.pagolink.co
APIhttps://api.pagolink.co/v1
API, cuentashttps://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 pagadorResultado
1111Rechazo del banco simulado. Llega el evento de pago fallido con código TEST_DECLINED.
2222Queda en revisión manual y aparece en Cobros › Por confirmar.
cualquier otroAprobado. 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étodoSin banco activoCon tu banco activo
Pago Móvil, TransferenciaEn 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ónNo disponibles.Disponibles.
Binance Pay, Cashea, cobro manualNo 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ódigoHTTPCuándo y qué hacer
NO_METHODS_AVAILABLE409Crear 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_ACTIVE409Crear en vivo una suscripción, o el enlace de un plan, sin el banco activo: se cobran por domiciliación.
DOMICILIATION_NOT_AVAILABLE409Crear 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_CONNECTION409Encender 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 basePuede
PropietarioTodo, incluidos los datos del negocio y transferir la propiedad. Siempre hay al menos uno.
AdministradorTodo menos organization.manage.
Desarrolladordevelopers.manage y todas las lecturas.
Cobranzacharges.read, charges.create, charges.confirm, payers.read, catalog.read, subscriptions.read, invoices.read.
Contadorcharges.read, charges.export, invoices.read, invoices.issue, invoices.void, payers.read, catalog.read, subscriptions.read.
Solo lecturaTodas las lecturas (*.read).
PermisoPermite
charges.readVer cobros, enlaces de cobro, métricas y liquidaciones.
charges.createCrear cobros y enlaces de cobro, editarlos y desactivarlos.
charges.confirmAprobar o rechazar pagos en Cobros › Por confirmar.
charges.refundReembolsar cobros desde la consola. La API pública no tiene ruta de reembolso.
charges.exportExportar cobros a CSV o Excel.
subscriptions.read, subscriptions.manageVer suscripciones, crearlas, cobrarlas al momento y cancelarlas.
invoices.readVer facturas y descargar su PDF.
invoices.issueEmitir facturas, reenviarlas y hacer notas de crédito.
invoices.voidAnular facturas.
catalog.read, catalog.manageVer y gestionar productos, precios, categorías, cupones y códigos.
payers.readVer pagadores.
payment_methods.manageEncender, apagar y configurar métodos de cobro.
collection_account.manageCuenta de cobro: solicitudes al banco, recaudos y datos fiscales del trámite.
settings.manageMarca, apariencia del checkout, facturación fiscal, Cashea, Binance Pay y notificaciones.
developers.manageClaves de API, webhooks y URLs de retorno del checkout.
team.manageInvitar, cambiar roles, quitar miembros y crear roles a medida.
organization.manageDatos 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 sobre exchangeRate).
// 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.

CampoTipoQué es
displayNametexto, de 1 a 80Nombre que ve el pagador.
logoUrlURL httpsLogo del encabezado.
iconUrlURL httpsÍcono del comercio.
brandColorhex #RGB o #RRGGBBColor de la marca.
backgroundColorhex #RGB o #RRGGBBColor de fondo del checkout.
fonttextogeist, barlow, inter, dm-sans, manrope, lora, poppins, montserrat, nunito, work-sans, ibm-plex-sans o playfair-display.
radiustextosquare, 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.

CampoTipoQué hace
expiresInMinutesentero, de 30 a 1440Cuánto dura la sesión antes de vencer. Por defecto, 30 minutos.
customerobjeto, opcionalDatos 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.
submitTypepay, book, donate o subscribeCambia el texto del botón principal: «Pagar», «Reservar», «Donar» o «Suscribirme», seguido del monto. Sin él, «Confirmar pago».
customText.submittexto, hasta 200Se muestra en chico debajo del botón.
customText.afterSubmittexto, hasta 500Se muestra en la pantalla de pago completado.
customText.termsOfServicetexto, hasta 500Se muestra junto a la casilla de términos.
customFieldslista de 0 a 3Campos 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.
termsUrlURL httpsEnlace 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:

  • 400 con code: "CUSTOM_FIELD_REQUIRED" y field: "<key>" cuando falta un campo obligatorio.
  • 400 con code: "CUSTOM_FIELD_INVALID" y field cuando el valor no encaja (un número con letras, o una opción que no existe).
  • 400 con code: "TERMS_NOT_ACCEPTED" cuando la sesión tiene consentCollection y 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:

ReglaLímite
Claveshasta 20
Longitud de la clave40 caracteres
Valorcadena, hasta 500 caracteres
Anidamientoninguno — 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.

El 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.

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();

Botón mountButton()

Renderiza un botón de pago ya diseñado con la marca pagolink (logo, monocromo, sin dependencias) dentro de un contenedor. Al hacer clic abre el checkout — o el terminal POS con mode: 'pos'.

const handle = pl.mountButton('#pagar', {   // selector o HTMLElement
  sessionId,
  label: 'Pagar con pagolink',   // opcional (default)
  mode:  'checkout',              // 'checkout' (default) | 'pos'
  theme: 'dark',                 // 'dark' (default) | 'light'
  onSuccess: (r) => { /* … */ },
});

// para remover el botón
handle.destroy();

Callbacks

CallbackPayload
onSuccess{ sessionId, status }
onCancel— (el usuario cerró el cobro)
onError{ code, message }
Confirma siempre el estado final en tu backend (webhook o consulta de la sesión). El callback del navegador es una señal de UX, no la fuente de verdad.

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

  1. Tu servidor crea una sesión con tu clave secreta (sk_…) y recibe un clientSecret.
  2. Tu página carga el SDK, monta el formulario con ese clientSecret y el pagador lo completa.
  3. Al confirmar, tu página llama a confirmPayment y muestra el resultado.
  4. Tu servidor recibe payment.completed por 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).

TemaQué es
pagolinkClaro, con fondo blanco, bordes finos y sin sombras. Es el de por defecto.
nightOscuro, para páginas con modo nocturno.
flatSin sombras, bordes llenos y radio de 4 px.
VariablePor defectoQué cambia
colorPrimary#0A0A0BFoco, opción elegida y acciones dentro del Element.
colorBackground#FFFFFFFondo del componente y de los campos.
colorText#0A0A0BTexto de campos, etiquetas y opciones.
colorDanger#B42318Mensajes de error y campos con un dato no válido.
fontFamilyGeist, system-uiFamilia de letra del componente.
fontSizeBase14pxTamaño base. Los demás textos se escalan a partir de él.
spacingUnit4pxUnidad de espacio. Los márgenes y el relleno son múltiplos.
borderRadius8pxRadio 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 https y localhost.

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 clientSecret autoriza 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 de confirmPayment es 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ódigoHTTPCuándo
INVALID_SESSION401La sesión no existe o el token no corresponde a ella.
SESSION_EXPIRED410La sesión venció. Crea una nueva en tu servidor.
SESSION_COMPLETED409La sesión ya se cobró o se está cobrando. Crea otra para otro cobro.
ELEMENTS_KEY_MISMATCH403La clave publicable no es del mismo comercio ni del mismo entorno que la sesión.
ELEMENTS_ORIGIN_NOT_ALLOWED403El 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

CampoTipoQué es
iduuidId interno.
publicIdtextoprod_…. El que conviene guardar en tu sistema.
nametextoLo que ve el pagador en el checkout. Hasta 120 caracteres.
descriptiontexto o nullHasta 500 caracteres.
kindtextoproduct o service (por defecto).
imageUrltexto o nullURL https de la imagen.
activebooleanoUn producto archivado (false) no se puede cobrar.
metadataobjeto o nullTus claves y valores, como texto.
defaultPriceIduuid o nullId interno del precio predeterminado. El primer precio que se crea queda como predeterminado solo.
pricesPrecio[]Todos sus precios, activos y archivados, del más nuevo al más viejo.
categoryobjeto o null{ id: "pcat_…", name }: la categoría del catálogo.
createdAt, updatedAtfecha 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.

ErrorCuándo
400Un campo inválido, o defaultPriceId que no es un precio activo de ese producto.
404El producto no existe o es de otro negocio.
409DELETE 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

CampoTipoQué es
id, publicIduuid, textoId interno y price_…. Cualquiera de los dos sirve como priceId.
productIduuidSu producto.
unitAmounttextoMonto por unidad con 2 decimales, "10.00". Nunca un número: así no se pierde un céntimo.
currencytextoUSD, EUR o VES.
typetextoone_time (pago único) o recurring.
intervaltexto o nullSolo 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).
activebooleanoUn precio archivado no abre cobros nuevos; los que ya salieron no cambian.
nicknametexto o nullUn nombre interno («Precio de lanzamiento»). El pagador no lo ve.
lookupKeytexto o nullTu clave estable para pedir el precio sin guardar su id (ver). Letras, números y _ . : -, hasta 100. Única entre los precios activos del negocio.
metadataobjeto o nullTus claves y valores.
createdAtfecha 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:

CampoQué hace
lookupKeyLe pone tu clave. Si otro precio activo ya la tiene, 409.
transferLookupKeytrue: si otro precio activo tiene la clave, se la quita y la pasa a este en vez de dar 409.
replacesPriceIdPrecio de este mismo producto que este reemplaza: se archiva en la misma operación.
setDefaulttrue: este precio pasa a ser el predeterminado.
metadataTus 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.

ErrorCuándo
400Un 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».
404El precio no existe o es de otro negocio.
409La 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
Cambiar el precio no cambia las suscripciones que ya existen: siguen cobrando su monto. Para moverlas al precio nuevo, cancélalas y créalas con el 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
CampoTipoQué es
amounttextoMonto con hasta 2 decimales, "10.00". Obligatorio sin priceId.
currencytextoUSD, 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.
priceIdtextoUn precio de pago único del catálogo (price_… o id interno). Con él no se mandan amount ni currency.
lineItemslistaVarios productos con cantidades: ver Enlaces con productos.
descriptiontextoLo que ve el pagador. Hasta 255.
expiresAtfecha ISODesde cuándo ya no abre cobros. Tiene que ser futura; sin ella, no vence.
singleUsebooleanoUn solo pago. Sin él, el enlace sirve para varios pagos.
payerName, payerDocument, payerEmail, payerPhonetextoA quién va dirigido (opcional). Se rellenan en el checkout y no salen en la consulta pública del enlace.
environmenttextolive 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, … }
CampoTipoQué es
lineItemslista (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[].quantityenteroCantidad inicial. Por defecto 1.
lineItems[].adjustableQuantityobjeto{ enabled, minimum, maximum }: el pagador cambia la cantidad dentro del rango (por defecto de 1 a 99). Sin él, la cantidad es fija.
restrictions.completedSessionsLimitenteroCuántos pagos completados admite. Al llegar, el enlace se desactiva solo y quien lo abre ve «agotado» (soldOut).
afterCompletionobjetoQué ve el pagador al terminar: { "type": "message", "message": "…" } (hasta 500) o { "type": "redirect", "url": "https://…" } (solo https).
issueInvoicebooleanoPor 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.
collectobjetoDatos 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ónPor defectoQué hace
pricesIncludeTaxsí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.
collectBuyersí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.

  1. Su banco domicilia. Sigue el flujo del mandato: datos, cuenta de 20 dígitos, aprobar en su banco y la sonda de 1 Bs.
  2. 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.
  3. 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ódigoHTTPCuándo
ITEMS_MIXED_CURRENCY400Los precios de las líneas están en monedas distintas.
PRICE_NOT_ONE_TIME400Una línea es un precio recurrente.
PRICE_ENVIRONMENT_MISMATCH400Un precio de prueba en un cobro real, o al revés.
DUPLICATE_PRICE400El mismo precio en dos líneas.
LINE_ITEMS_COUNT400Menos de 1 o más de 20 líneas.
QUANTITY_OUT_OF_RANGE400Una cantidad fuera del rango de su línea, o cambiar la de una línea fija.
UNKNOWN_LINE_ITEM400Una cantidad para un precio que no está en el enlace.
PAYER_FIELD_REQUIRED, PAYER_FIELD_INVALID400Falta un dato que el enlace pide, o no tiene un formato válido. field dice cuál.
LINK_SOLD_OUT409El enlace llegó a su límite de pagos.
SUBSCRIPTION_PLAN_REQUIRED400noUn 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_RECURRING400noUna suscripción necesita un precio recurrente, no uno de pago único.
TRIAL_AND_START_DATE400noEl plan lleva días de prueba o fecha de inicio, no los dos.
START_DATE_IN_PAST400noLa fecha de inicio del plan tiene que ser futura.
KIND_MISMATCH400noUn precio recurrente es de una suscripción: crea el enlace con kind: "subscription".
INVALID_BANK400noEl código no es de un banco de la lista de bancos del país.
NO_DEFAULT_PRICE409El producto no tiene precio predeterminado activo y no se mandó priceId.
PRICE_NOT_IN_PRODUCT400El 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": { … } }
CampoTipoQué es
percentOffdecimalDe 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.
durationtextoPara suscripciones: once (hasta el primer cobro que entre), repeating con durationInMonths, o forever. En un cobro suelto da igual.
appliesTo.productslistaProductos (prod_…) a los que aplica: el descuento va solo sobre esas líneas. Sin ella, sobre toda la compra.
redeemBy, maxRedemptionsfecha, enteroHasta cuándo y cuántas veces se puede usar. valid dice si hoy se puede.
codetextoLetras, 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ódigoHTTPCuándo
PROMOTION_CODE_INVALID, COUPON_NOT_FOUND400El código no existe en este entorno; el cupón no existe.
COUPON_EXPIRED, PROMOTION_CODE_EXPIRED400Pasó redeemBy o expiresAt.
COUPON_MAX_REDEMPTIONS, PROMOTION_CODE_MAX_REDEMPTIONS400 / 409Se usó todas las veces permitidas: 400 al aplicarlo, 409 si se agotó mientras se pagaba.
COUPON_INACTIVE, PROMOTION_CODE_INACTIVE400Desactivado.
MINIMUM_AMOUNT_NOT_MET400La compra no llega al mínimo del código, o está en otra moneda.
COUPON_NOT_APPLICABLE400Ningún producto de la compra está en appliesTo.
COUPON_CURRENCY_MISMATCH400Monto fijo en una moneda distinta a la de la compra.
DISCOUNT_COVERS_TOTAL400El descuento deja el cobro en cero.
PROMOTION_CODES_NOT_ALLOWED400El enlace o la sesión no acepta códigos.
SESSION_ALREADY_ATTEMPTED409Ya hubo un intento de pago en la sesión: el monto no cambia.
DISCOUNTS_COUNT, DISCOUNT_NOT_SUPPORTED400Má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):

  1. Crea tu cuenta de comercio en Binance Pay Merchant. Es distinta a la personal: Binance verifica tu empresa.
  2. Genera una clave de API en la sección para desarrolladores del portal. El API secret solo se ve una vez.
  3. 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.
  4. 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ódigoHTTPQué pasó
BINANCE_CREDENTIALS_INVALID400Binance no aceptó la credencial (binanceCode trae su código).
BINANCE_CREDENTIALS_MALFORMED400No parece una credencial: incompleta o con espacios.
BINANCE_UNAVAILABLE503Binance no respondió; intenta de nuevo.
BINANCE_NEEDS_CREDENTIALS409 / 400Encender 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.

  1. 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.
  2. Activa. Al firmar, la suscripción pasa a active con mandate.provider: "binance" y sale mandate.authorized.
  3. 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" con reason: "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.
  4. 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_changed con reason: "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ódigoHTTPQué pasó
BINANCE_NOT_AVAILABLE409El plan no se puede cobrar con Binance (reason: not_enabled, currency, interval o limit).
BINANCE_CONTRACT_FAILED409Binance no creó la autorización; intenta de nuevo.
BINANCE_SCHEDULED_CHARGE_UNSUPPORTED409Un 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:

  1. Afíliate a Cashea como comercio: llena su formulario de afiliación; Cashea revisa, te asigna un ejecutivo y activa tu tienda.
  2. Pide tu API Key a tu ejecutivo.
  3. Pega la API Key y elige la caja.
  4. Indica dónde te deposita Cashea.
  5. 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
  }'
CampoTipoQué es
customerobjetotaxId (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.
currencytextoVES, USD o EUR. USDT se declara como USD.
lineslistaDe 1 a 100: description, quantity (hasta 4 decimales), unitPrice, taxCategory, discount (monto de la línea) y code.
taxCategorytextoSTANDARD (16 %, por defecto), REDUCED (8 %), LUXURY (31 %), ZERO, EXEMPT, EXONERATED, NOT_SUBJECT.
pricesIncludeTaxbooleanotrue: 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.
paymentslistaCó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ó.
fxRatedecimalBolí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.
sendEmailbooleanoManda 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ódigoHTTPCuándo
INVOICING_DISABLED409La facturación no está activada.
ISSUER_INCOMPLETE409Faltan el RIF, la razón social o el domicilio fiscal del comercio.
ISSUER_NOT_READY409La imprenta digital todavía no está lista para emitir.
TAX_SERVICE_UNAVAILABLE503El servicio fiscal no está disponible; reintenta más tarde.
FX_RATE_UNAVAILABLE409Factura en divisas sin fxRate y sin tasa BCV cargada.
PAYMENTS_REQUIRED400Factura en divisas sin payments.
IDEMPOTENCY_KEY_REUSED409La Idempotency-Key ya se usó con otro cuerpo.
INVOICE_NOT_ISSUED409Nota, anulación o correo sobre una factura no emitida.
INVOICE_ALREADY_VOIDED409La factura ya está anulada; una nota de crédito sobre una factura anulada responde con este código.
PDF_UNAVAILABLE409La factura no tiene número todavía, o la imprenta no devolvió el archivo.
INVALID_CREDIT_NOTE_LINE400La nota nombra una línea que no existe o acredita más de lo facturado.
CREDIT_NOTE_REJECTED, VOID_REJECTED422El servicio fiscal rechazó la nota o la anulación; el mensaje dice por qué.
TAX_SERVICE_ERROR502El servicio fiscal no respondió; reintenta.
INVOICE_NOT_FOUND / TRANSACTION_NOT_FOUND404La factura o el cobro no existen en el entorno de tu clave.
CUSTOMER_EMAIL_REQUIRED400send 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ámetroQué es
currencyUSD (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.
ErrorCuándo
400Una moneda que la fuente no publica, un par sin la moneda local, un monto o una fecha inválidos.
401Clave o sesión inválida. convert funciona sin credenciales, pero si las mandas tienen que ser válidas.
404current cuando todavía no hay ninguna tasa cargada.
409convert 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.

Se comprueba solo Lo confirma el comercio
MétodoComprobaciónQué pide
Pago MóvilAutomáticaEl cliente paga desde su banco; pagolink comprueba el pago entrante contra la cuenta.
C2PAutomáticaBanco, teléfono y clave C2P del cliente.
Débito InmediatoAutomáticaOTP de 8 dígitos enviado por SMS al cliente.
TransferenciaAutomáticaDatos de cobro; se comprueba el abono contra la cuenta.
TarjetaAutomáticaTarjetas 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 PayAutomáticaQR; el cliente escanea y paga en USDT, que entra a tu cuenta de Binance Pay.
Cobro manualManualUn 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».
PayPalManualLa cuenta PayPal del comercio; el comercio confirma el pago recibido.
Transferencia internacionalManualLos datos bancarios del comercio en el exterior; el comercio confirma el pago recibido.
La comprobación automática de los métodos de banco corre contra el banco del comercio y empieza cuando tu banco está activo; antes, Pago Móvil y Transferencia los confirmas tú y C2P y Débito Inmediato no se ofrecen (ver Activar tu cuenta). Hoy hay un banco integrado: Banco Plaza (código 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.

EstadoQué significa
active / offEncendido o apagado por ti.
can_activateTu banco ya lo aprobó; falta encenderlo.
needs_connectionTu cuenta de cobro no lo incluye; se pide al banco.
pending / rejectedLa solicitud al banco está en trámite o fue rechazada.
rejected_by_bankEl banco no habilitó ese proceso (bankCode trae su código).
needs_credentialsBinance Pay: falta tu API key y tu API secret del entorno. Cashea: falta la clave o la caja.
needs_setupCobro manual: falta su nombre e instrucciones.
coming_soonTodavía no se ofrece (envío de Pago Móvil y de transferencias).
simulatedModo 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.

pagolink nunca almacena el número, el vencimiento, el CVV ni el PIN. Solo conserva la marca y los últimos 4 dígitos.

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".

RespuestaQué significa
payment_retryUn dato se puede corregir; la sesión sigue pending y el pagador reintenta.
payment_declinedEl banco rechazó el pago de forma definitiva (failed, retryable: false). El checkout ofrece otro método.
payment_pending_reconciliationEl 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.

El payload histórico v1 es { 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

CabeceraPara qué
X-Pagolink-SignatureFirma v2, v1=<hex> — HMAC-SHA256 de "<timestamp>.<cuerpo crudo>" con el secreto del endpoint. Es la que hay que verificar.
X-Pagolink-TimestampEpoch en segundos. Va dentro de la firma, así que la firma caduca. Tolerancia sugerida: 5 minutos.
X-Payment-SignatureFirma 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.
Un endpoint sin secreto no se intenta siquiera: no se firma con vacío, la entrega falla en el sitio. Configura el secreto al registrar la URL.

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

EventoCuándoQué hacer
payment.completedEl pago entróCerrar la factura. El dinero ya está en la cuenta
payment.failedRechazadoDejar la factura abierta
payment.expiredEl enlace caducó sin pagarseDejar la factura abierta
payment.pending_reviewEsperando comprobación«Verificando». Aún no cierres nada
payment.updatedCualquier otro cambio de estado de un cobro, sin evento propioLeer data.status antes de actuar
refund.completedDevueltoReabrir o abonar
mandate.authorizedEl cliente autorizó y el banco aprobó la domiciliación: la autorización queda activaActivar el acceso al plan
subscription.chargedCobro del plan liquidado, o marcado pagado (paidOutside: true)Cerrar la cuota
subscription.payment_link_sentSe abrió el cobro de una fecha de un plan que se cobra por enlace y salió su enlace de pagoInformar al cliente si quieres avisarle tú; el cobro llega como subscription.charged
subscription.collection_method_changedEl 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.createdSe creó la suscripciónGuardar el sub_…
subscription.updatedCambió la metadata, el externalId o el monto de los próximos cobros, o se programó o deshizo su cancelación. previous trae lo anteriorSincronizar tu registro
subscription.charge_failedFalló un cobro del plan. Trae attempt y nextAttemptAt (null si no habrá otro intento)Avisar al cliente si nextAttemptAt es null
subscription.charge_review_requiredEl banco no confirmó un cobro del plan en 72 horas y quedó por revisarRevisar tu estado de cuenta y resolverlo
subscription.past_dueSe acabaron los intentos (o el banco rechazó de forma definitiva) y la suscripción quedó en moraAvisar al cliente; no cierres la cuota
subscription.canceledLa suscripción se canceló. cancelReason dice por quéDejar de dar el servicio
subscription.upcoming_chargeFaltan menos de 48 horas para el próximo cobro del plan (un aviso por ciclo)Avisar al cliente antes del cobro
subscription.cycle_uncollectibleCerraste un ciclo como incobrable a manoRegistrar la cuota como perdida
subscription_charge.createdSe programó un cobro puntual de la suscripciónGuardar el sch_…
subscription_charge.completedCobrado, o marcado pagado (paidOutside: true)Cerrar el cargo: es dinero recibido
subscription_charge.failedFalló un intento. data.willRetry dice si habrá otroCon willRetry: false, avisar o marcarlo pagado
subscription_charge.canceledCancelado a mano o al cancelarse la suscripciónQuitarlo de tus cobros pendientes
invoice.issuedLa factura quedó emitida (número y control asignados)Guardar el número y el PDF
invoice.failedLa factura no se emitióRevisar failureReason, corregir y reintentar
invoice.voidedLa factura se anulóMarcarla anulada
invoice.credit_note.issuedSe emitió una nota de créditoRegistrar el abono
merchant.onboarding.bank_pendingEl operador envió la solicitud de la cuenta de cobro al bancoMostrar «en trámite con el banco»
merchant.onboarding.returnedFalta información: el trámite vuelve al comercio para completarloPedir al comercio lo que indica el trámite
merchant.onboarding.rejectedEl trámite de la cuenta de cobro fue rechazadoMostrar el motivo y no habilitar cobros
merchant.onboarding.activeLa cuenta de cobro quedó activa con su bancoHabilitar los métodos que indique methods
merchant.method.enabledSe habilitó un método de cobro del banco; llega un evento por métodoMostrar el método como disponible
webhook.testEvento de prueba enviado desde la consolaNinguna: solo confirma la ruta

Datos de cada evento

Eventodata
payment.*, refund.completedLa transacción pública, con metadata solo de tus claves (ver metadata).
subscription.*, mandate.authorizedLa 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.chargedLo anterior, más transactionId. Si lo marcaste tú, además paidOutside: true.
subscription.canceledLo 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_sentLo 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_changedLo 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_chargeLo anterior, más nextChargeAt, amount (monto del próximo cargo, con su descuento) y currency.
subscription.cycle_uncollectibleLo 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_pendingprojectId, provider (por ejemplo banco-plaza) y caseRef (la referencia del banco, o null).
merchant.onboarding.returned, merchant.onboarding.rejectedprojectId, provider y reason: el motivo que escribió el operador.
merchant.onboarding.activeprojectId, provider, methods (los métodos pedidos: pago-movil, transferencia, c2p, cobro-inmediato, domiciliacion) y accounts (cuántas cuentas quedaron activas).
merchant.method.enabledprojectId, 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:

  1. El rechazado sigue siendo payment.failed, igual que siempre. No cambia.
  2. pagolink abre un cobro nuevo por el mismo monto, con tu misma description, reference, URLs de retorno y tu metadata, más metadata.retryOf con el sessionId del rechazado (y metadata.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.

Intento12345
Espera60 s5 min15 min1 h2 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ódigoHTTPCuándo
EMAIL_INVALID400El correo no tiene un formato válido.
INVALID_CODE400Código incorrecto, vencido o ya usado. attemptsLeft dice cuántos intentos quedan.
TOO_MANY_ATTEMPTS429Cinco intentos malos: el código deja de valer. Pide uno nuevo.
UNAUTHORIZED401Falta el token del portal, o venció.
PUBLIC_KEY_REQUIRED400La marca del portal pide la clave publicable pk_.
404404La 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 el sessionId de corta vida.
  • El cobro corre en un iframe del origin de pagolink; el SDK valida el origin del 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-Signature sobre 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:

  1. 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.
  2. 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.
  3. 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.»
  4. 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.
  5. Cobros siguientes. Los cobros salen en cada fecha. Si uno falla (fondos insuficientes, límite del cliente MD15, cliente que suspendió al comercio MD22…), 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).
  6. 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 consolaEn la APIQué significa
Pendiente de aprobaciónpending_authorizationEl cliente todavía no la autorizó, o la autorizó y su banco todavía no la aprobó. El cobro no sale.
AprobadaactiveEl banco la aprobó (affiliation: "affiliated") y el plan cobra en cada fecha.
RevocadarevokedEl 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.

En la API, el cobro de verificación tiene el trigger 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, … }
CampoValorQué significa
affiliationnullEl cliente todavía no autorizó la domiciliación.
pendingAutorizada. El banco espera que el cliente apruebe la domiciliación en su banca en línea.
affiliatedAprobada por el banco: la autorización está activa y la suscripción cobra.
affiliationProbe{ amount, currency } o nullEl 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:

affiliationPrimer cobro (firstCharge)Qué hacer
affiliatedEl 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.
pendingnull. 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 affiliated sin 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én 409.
  • 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.authorized y el primer cobro sigue la configuración de la suscripción. Sin aprobación, sigue esperando sin gastar reintentos.
Un cobro de verificación que el banco acepta no tiene comisión. Si el banco no responde con seguridad (una respuesta ambigua), el cobro de verificación queda en curso y la respuesta es 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ódigoSignificadoQué pasa
RC, MD01, MD09, MD22Afiliació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.
MD15El cobro excede el límite del cliente.Cobro fallido; se reintenta según las reglas del comercio.
MD21El cobro no cumple los parámetros del cliente.Cobro fallido.
CUSTEl cliente canceló el cobro.Cobro fallido.
AM04, A12, 0042Fondos insuficientes.Cobro fallido; se reintenta según las reglas del comercio.
0004Fuera de la franja horaria del banco.Se reintenta.
PEPendiente 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 }
CampoValoresQué hace
maxRetries0 a 5 (por defecto 3)Reintentos tras el primer rechazo. 0 no reintenta.
intervalDays1 a 7 (por defecto 1)Días entre intentos.
onExhaustedpast_due o cancelAl acabarse los intentos, o ante un rechazo definitivo: dejar en mora con el enlace de pago, o cancelar de inmediato (cancelReason: payment_failure).
pastDueDays1 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

CampoTipoQué hace
amounttexto o número, mayor que cero, 2 decimalesMonto del cobro.
currencyUSD, EUR o VESEn divisas, el banco recibe bolívares a la tasa BCV del momento de cada intento.
descriptiontexto, hasta 120Concepto del cobro. El banco recibe los primeros 60 caracteres.
scheduledAtfecha ISOCuá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.
maxAttemptsentero, de 1 a 5Intentos antes de dar el cobro por fallido. Por defecto, 3.
retryIntervalHoursentero, de 1 a 168Horas entre un intento fallido y el siguiente. Por defecto, 24.
taxCategorySTANDARD, REDUCED, LUXURY o EXEMPTIVA del cobro cuando el precio no lo incluye. Por defecto, STANDARD.
metadataobjetoTus 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 en processing no se toca: cancelarlo, reintentarlo o marcarlo pagado responde 409 (CHARGE_IN_PROGRESS, CHARGE_NOT_CANCELABLE o CHARGE_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 scheduled una 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) o bank_returned (la liquidación devolvió el cobro). Si quedan intentos, el cobro vuelve a scheduled con nextAttemptAt = ahora + retryIntervalHours; si no, queda failed. failureReason guarda el motivo del último intento.
  • Éxito. completed con completedAt y el transactionId (txn_…) del intento. Es un cobro real: pasa por la liquidación normal, con comisión, factura y el webhook payment.completed. Un cobro programado no emite subscription.charged: su evento es subscription_charge.completed.
  • Cancelar la suscripción cancela sus cobros scheduled, con un webhook subscription_charge.canceled por cada uno.

Cancelar, reintentar y marcar pagado

  • POST …/cancel solo sirve desde scheduled. Devuelve el cobro con status: "canceled". Desde cualquier otro estado, 409 CHARGE_NOT_CANCELABLE.
  • POST …/retry solo sirve desde failed. Devuelve el cobro a scheduled con attempts en cero y failureReason en null; scheduledAt y nextAttemptAt pasan a scheduledAt de la petición o, si no lo mandas, a ahora. Retry no emite webhook. Desde otro estado, 409 CHARGE_NOT_RETRYABLE.
  • POST …/mark-paid registra un pago recibido fuera de pagolink (efectivo, transferencia directa). Sirve desde scheduled o failed y devuelve el cobro con status: "completed" y paidOutside: true. No hay transacción del banco, comisión ni factura. reference (hasta 60) y note (hasta 200) se guardan en la metadata del cobro. Con el cobro en processing, 409 CHARGE_IN_PROGRESS; si ya está completed o canceled, 409 CONFLICT.

Webhooks

EventoCuándoQué hacer
subscription_charge.createdSe programó el cobro. data es el cobro, con la misma forma de la respuesta.Guardar el sch_… con tu registro.
subscription_charge.completedCobrado, o marcado pagado (paidOutside: true).Cerrar el cargo: es dinero recibido.
subscription_charge.failedFalló un intento. data.willRetry dice si habrá otro.Con willRetry: false, avisar al cliente o marcarlo pagado.
subscription_charge.canceledCancelado 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 manual completada 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 active con los reintentos en cero. Si venía de past_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_…) y paidOutside: true.
  • Webhook subscription.charged con la misma suscripción, transactionId y paidOutside: true.
  • Si hay un cobro del plan en curso (processing), responde 409 CHARGE_IN_PROGRESS.

Marcar incobrable

  • Perdona el ciclo: no se cobra ni entra dinero. Los reintentos quedan en cero, la suscripción vuelve a active sin mora y, si venía de past_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ás note.

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_due o active con retryCount mayor 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 en scheduled con intentos y un failureReason. 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" }. La url es 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 active con retryCount en cero. Si venía de past_due, su próximo cobro se recorre un intervalo desde ahora. Sale el webhook subscription.charged con paidVia: "checkout" y el transactionId.
  • Cobro programado. Pasa a completed con su transactionId y la metadata del cobro lleva paidVia: "checkout". Sale el webhook subscription_charge.completed con paidVia: "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 enResultado
1111Rechazo temporal: se reintenta según tu política de reintentos.
2222Rechazo definitivo (cuenta cancelada): no se reintenta.
3333Queda en proceso y se liquida solo al minuto.
4444El banco nunca responde: pasa a «por revisar» a los 5 minutos (en vivo, a las 72 horas).
otraSe 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
CampoTipoQué es
nametexto, hasta 80Nombre para ti. El cliente no lo ve.
eventNameminúsculas, números, _ . -; de 1 a 64Identifica el medidor en los eventos. Única por negocio y entorno: si ya existe, 409 METER_EVENT_NAME_TAKEN.
aggregationsum, count o lastsum (por defecto) suma los value; count cuenta eventos sin mirar su valor; last toma el value del evento más reciente del período.
activebooleanoUn 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
  • identifier deduplica. Un reintento con el mismo identificador en el mismo medidor responde 200 con 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).
  • id del evento es un uuid. value vuelve como texto; por defecto, 1.
  • timestamp es 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_FOUND si el eventName no existe; 409 METER_INACTIVE; 422 SUBSCRIPTION_NOT_METERED si 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:

billingSchemeCómo cobra el usoCampos
flatNo cobra uso. Es el valor por defecto. Su monto tiene que ser mayor que cero.—
per_unitCada unidad usada, al mismo precio.usageUnitAmount, texto con hasta 6 decimales.
tieredPor tramos: cada tramo cobra solo las unidades que caen en él.tiers
volumeUn 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 flat exige type: "recurring" y un medidor activo del mismo entorno. Si el medidor no existe o está desactivado, 404 METER_NOT_FOUND.
  • per_unit exige usageUnitAmount y no admite tiers. tiered y volume exigen tiers y no admiten usageUnitAmount. flat no admite ninguno de los tres.
  • Si no cuadra, 400 INVALID_BILLING_SCHEME. Las respuestas de precio traen billingScheme, meter (mtr_… o null), usageUnitAmount y tiers.

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 en metadata.period (start y end).

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

CampoTipoQué hace
amounttexto decimal, 2 decimales como máximoMonto en bolívares. La moneda siempre es VES.
beneficiaryBank4 dígitosCódigo del banco del beneficiario (0191 para BNC). Ver Bancos.
beneficiaryIdcédula o RIFV12345678, J000121532; letras V, E, J, G o P, con o sin guion.
beneficiaryNametexto, de 2 a 120Nombre del beneficiario.
beneficiaryPhonede 10 a 15 dígitosTeléfono móvil con código de área: 04142345678 o 584142345678.
reasontexto, hasta 120Concepto 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.

CasoRespuesta
Primera vezSe 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 ruta409 IDEMPOTENCY_KEY_REUSED.
Misma clave, todavía en curso409 IDEMPOTENCY_REQUEST_IN_PROGRESS. Espera unos segundos y repite con la misma clave.
Clave no válida400 INVALID_IDEMPOTENCY_KEY. Son de 1 a 255 caracteres visibles ASCII, sin espacios.
Falta en una ruta que la exige400 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 environment y, 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-Replayed se 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.

  • code es lo que compara tu código. El texto de message está en español y puede cambiar.
  • retryable dice si reintentar la misma petición, sin cambiarla, puede funcionar: saturación, un servicio externo caído, un límite de peticiones. Si es false, hay que corregir algo antes de repetir.
  • details.fields aparece solo en VALIDATION_FAILED. Ahí message es una lista de frases, una por campo que falló, y fields trae el nombre de cada campo.
  • INTERNAL_ERROR responde 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ódigoHTTPReintentableQué hacer
INVALID_REQUEST400noLa petición no es válida. Revisa el cuerpo y los parámetros.
UNAUTHORIZED401noFalta la clave de API o no es válida. Revisa x-api-key o inicia sesión de nuevo.
FORBIDDEN403noTu usuario o tu clave no tiene permiso para esta acción.
NOT_FOUND404noLa ruta o el recurso no existe en este comercio y entorno. Revisa el id y el entorno.
CONFLICT409noLa petición choca con el estado actual del recurso. Consúltalo y decide cómo seguir.
UNPROCESSABLE422noLa petición tiene la forma correcta, pero no se puede procesar con los datos actuales.
RATE_LIMITED429síHiciste demasiadas peticiones seguidas. Espera un momento y reintenta.
INTERNAL_ERROR500síOcurrió un error interno en pagolink. Reintenta; si sigue, avisa a soporte con la hora de la petición.
SERVICE_UNAVAILABLE503síEl servicio no está disponible en este momento. Reintenta en unos minutos.
VALIDATION_FAILED400noUno o más campos no cumplen las reglas. La lista de campos está en details.fields.
INVALID_JSON400noEl cuerpo de la petición no es un JSON válido.

Idempotencia

CódigoHTTPReintentableQué hacer
IDEMPOTENCY_KEY_REQUIRED400noEsta petición exige la cabecera Idempotency-Key. Envía una clave única por operación.
INVALID_IDEMPOTENCY_KEY400noLa Idempotency-Key no es válida: de 1 a 255 caracteres visibles, sin espacios.
IDEMPOTENCY_KEY_REUSED409noEsa Idempotency-Key ya se usó con otra petición. Usa una clave nueva para la nueva operación.
IDEMPOTENCY_REQUEST_IN_PROGRESS409síLa petición con esa Idempotency-Key todavía se está procesando. Espera y reintenta con la misma clave.

Cobros, enlaces y sesiones

CódigoHTTPReintentableQué hacer
BANK_NOT_ACTIVE409noLas suscripciones se cobran por domiciliación y tu banco no está activo. Complétalo en Cuenta de cobro.
DOMICILIATION_NOT_AVAILABLE409noTu 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_PAID409noEste enlace ya se pagó. Crea un enlace nuevo para otro cobro.
LINK_SOLD_OUT409noEste enlace ya no está disponible: se vendió lo que ofrecía el comercio.
LINK_IS_SUBSCRIPTION400noEste enlace es de una suscripción: el cliente debe suscribirse, no pagar una vez.
LINK_IS_NOT_SUBSCRIPTION400noEste enlace es de un pago, no de una suscripción.
NO_DEFAULT_PRICE409noEl producto no tiene un precio predeterminado activo. Asígnale uno o elige el precio del enlace.
NO_METHODS_AVAILABLE409noNo hay ningún método de cobro activo. Activa al menos uno en Métodos.
METHOD_NEEDS_CONNECTION409noEste método necesita la conexión con el banco. Complétala en Cuenta de cobro.
METHOD_NOT_AVAILABLE400noEse método no está disponible para este pago. Elige otro.
SESSION_ALREADY_ATTEMPTED409noYa se intentó pagar este cobro, así que el monto no se puede cambiar. Crea una sesión nueva.
TERMS_NOT_ACCEPTED400noEl pagador tiene que aceptar los términos para continuar.
CUSTOM_FIELD_REQUIRED400noFalta un dato que el comercio pide para el cobro. Revisa field.
CUSTOM_FIELD_INVALID400noUn dato que pide el comercio no tiene el formato esperado. Revisa field.
PAYER_FIELD_REQUIRED400noFalta un dato del pagador que el cobro exige. Revisa field.
PAYER_FIELD_INVALID400noUn dato del pagador no tiene el formato esperado. Revisa field.
LINE_ITEMS_COUNT400noUn cobro lleva entre 1 y el máximo de productos permitido.
UNKNOWN_LINE_ITEM400noUn precio indicado no está en este enlace.
UNKNOWN_PRODUCT400noAlgún producto no existe en este entorno. Revisa los ids.
PRICE_NOT_IN_PRODUCT400noEl precio no es un precio activo de este producto.
PRICE_NOT_ONE_TIME400noEl precio es recurrente: en un cobro solo van precios de pago único.
PRICE_ENVIRONMENT_MISMATCH400noEl precio es de otro entorno (prueba o vivo) que el cobro. Usa un precio del mismo entorno.
DUPLICATE_PRICE400noUn precio está repetido en el cobro. Indica la cantidad en una sola línea.
ITEMS_MIXED_CURRENCY400noLos productos del cobro tienen precio en monedas distintas. Usa productos de una sola moneda.
QUANTITY_OUT_OF_RANGE400noLa cantidad de un producto está fuera del rango permitido.
SUBSCRIPTIONS_NOT_AVAILABLE409noEste comercio todavía no puede cobrar suscripciones. Revisa la configuración de su cuenta.
DOMICILIACION_BANK_NOT_ALLOWED400noEsta domiciliación solo acepta cuentas de los bancos que indica allowedBanks.
EMAIL_INVALID400noEl correo no tiene un formato válido.
INVALID_CODE400noEl código no es válido. Pide uno nuevo si ya venció.
TOO_MANY_ATTEMPTS429noSe agotaron los intentos del código. Pide un código nuevo.
PUBLIC_KEY_REQUIRED400noEsta ruta pide la clave publicable (pk_) del comercio.
TRANSACTION_NOT_FOUND404noNo existe ese cobro en este entorno. Revisa el id y el entorno.

Descuentos y cupones

CódigoHTTPReintentableQué hacer
COUPON_NOT_FOUND400noEse cupón no existe en este entorno.
COUPON_VALUE400noEl valor del cupón no es válido: indica percentOff o amountOff, uno de los dos.
COUPON_DURATION400noLa duración del cupón no es válida: durationInMonths solo va con duration repeating.
COUPON_INACTIVE400noEste descuento ya no está disponible.
COUPON_EXPIRED400noEste descuento venció.
COUPON_MAX_REDEMPTIONS400 / 409noEste descuento ya se usó todas las veces permitidas. Quítalo y cobra sin él.
COUPON_NOT_APPLICABLE400noEste descuento no aplica a los productos de esta compra.
COUPON_CURRENCY_MISMATCH400noLa moneda del descuento no es la de la compra.
DISCOUNT_REF400noIndica coupon o promotionCode, uno de los dos.
DISCOUNT_COVERS_TOTAL400noEl descuento cubre toda la compra: este cobro no se puede hacer por pagolink.
DISCOUNT_NOT_SUPPORTED400noUn cobro con tasa o montos propios no admite descuentos.
DISCOUNTS_COUNT400noUn cobro o una suscripción admite un solo descuento.
MINIMUM_AMOUNT_NOT_MET400noLa compra no llega al monto mínimo que pide este código.
PROMOTION_CODES_NOT_ALLOWED400noEste cobro o enlace no acepta códigos de descuento.
PROMOTION_CODE_INVALID400noEse código no es válido.
PROMOTION_CODE_INACTIVE400noEste código ya no está disponible.
PROMOTION_CODE_EXPIRED400noEste código venció.
PROMOTION_CODE_MAX_REDEMPTIONS400 / 409noEste código ya se usó todas las veces permitidas. Quítalo y cobra sin él.
PROMOTION_CODE_MINIMUM400noLa compra mínima necesita minimumAmountCurrency.
PROMOTION_CODE_TAKEN409noYa hay un código activo igual en este entorno. Usa otro nombre.

Catálogo

CódigoHTTPReintentableQué hacer
UNKNOWN_CATEGORY400noEsa categoría no existe en este entorno.
CATEGORY_NAME_TAKEN409noYa hay una categoría con ese nombre.
CATEGORY_NOT_EMPTY409noLa categoría tiene productos. Muévelos antes de borrarla.

Métodos de pago externos

CódigoHTTPReintentableQué hacer
BINANCE_NEEDS_CREDENTIALS400noBinance Pay no está configurado para este comercio. Carga su API key y su API secret.
BINANCE_CREDENTIALS_MALFORMED400noLa API key o el API secret de Binance están incompletos. Cópialos completos desde Binance Pay.
BINANCE_CREDENTIALS_INVALID400noBinance no aceptó la API key y el API secret. Revisa que sean de Binance Pay (Merchant).
BINANCE_ORDER_REJECTED400noBinance Pay no aceptó este cobro. Paga con otro método.
BINANCE_UNAVAILABLE503síBinance Pay no respondió. Reintenta en unos minutos o usa otro método.
CASHEA_INCOMPLETE400noPara encender Cashea hacen falta la API key y la caja.
CASHEA_KEY_INVALID400noEsa no parece una API key de Cashea.
CASHEA_POS_UNKNOWN400noEsa caja no está en la cuenta de Cashea.
CASHEA_QR_INVALID400noEse no parece el QR de una caja de Cashea.
CASHEA_USD_ONLY400noCashea solo está disponible para cobros en dólares.
CASHEA_REJECTED400noCashea rechazó la operación. El mensaje indica el motivo que dio.
CASHEA_UNAVAILABLE503síCashea no respondió. Reintenta en unos minutos.

Facturación fiscal

CódigoHTTPReintentableQué hacer
INVOICING_DISABLED409noEl comercio no tiene la facturación activada. Actívala en Ajustes con sus datos fiscales.
ISSUER_INCOMPLETE409noFaltan datos del emisor para facturar. El mensaje indica cuáles.
ISSUER_NOT_READY409noLa imprenta digital del comercio todavía no está lista. Carga sus credenciales o pide el alta.
TAX_SERVICE_UNAVAILABLE503síLa facturación no está disponible en este entorno en este momento.
TAX_SERVICE_ERROR502síEl servicio fiscal no respondió. Reintenta la misma petición en unos minutos.
FX_RATE_UNAVAILABLE409síNo hay tasa BCV publicada para esa moneda. Indica fxRate o reintenta cuando se publique.
INVOICE_NOT_FOUND404noNo existe esa factura o el enlace venció.
INVOICE_NOT_ISSUED409noLa factura todavía no está emitida. Espera a que la imprenta la emita.
INVOICE_ALREADY_VOIDED409noLa factura ya está anulada.
PDF_UNAVAILABLE409noLa factura todavía no tiene número: no hay documento que descargar.
NUMBERING_UNAVAILABLE422noLa imprenta no pudo asignar el número de la factura. Revisa el rango de numeración.
CREDIT_NOTE_REJECTED422noLa imprenta rechazó la nota de crédito. El mensaje indica qué corregir.
VOID_REJECTED422noLa imprenta rechazó la anulación. El mensaje indica qué corregir.
CUSTOMER_EMAIL_REQUIRED400noLa factura no tiene correo del cliente. Indica email.
INVALID_TAX_ID400noEl RIF o la cédula del cliente no es válido: letra V, E, J, P o G y el número.
INVALID_FILTER400noUn filtro de la consulta no es válido. El mensaje indica cuál y cómo escribirlo.
INVALID_CREDIT_NOTE_LINE400noLa línea de la nota de crédito no existe en la factura o supera lo facturado.
INVALID_AMOUNT400noUn monto de la factura no es válido. Usa solo dígitos y punto decimal.
INVALID_QUANTITY400noLa cantidad de una línea tiene que ser mayor que cero.
INVALID_DISCOUNT400noEl descuento de una línea supera el monto de la línea.
NO_LINES400noLa factura necesita al menos una línea.
UNKNOWN_TAX_CATEGORY400noLa categoría fiscal de una línea no existe. Usa una de las que indica el mensaje.
UNKNOWN_PAYMENT_METHOD400noUn método de pago de la factura no existe. Usa uno de los que indica el mensaje.
PAYMENTS_REQUIRED400noIndica cómo pagó el cliente: en una factura en divisas el IGTF depende de eso.
PAYMENT_METHOD_CURRENCY400noUn método de pago no corresponde a la moneda del pago.
PAYMENT_MISMATCH400noLo pagado no cuadra con el total de la factura. Revisa los montos de los pagos.
INVALID_INVOICE400noLa factura no es válida. El mensaje indica qué revisar.
INVALID_SCHEDULE400noLa 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ódigoHTTPReintentableQué hacer
PERMISSION_DENIED403noNo tienes permiso para esa acción. Pídeselo a quien administra el equipo.
PERMISSION_NOT_DECLARED403noEsa acción no está disponible desde la consola.

Suscripciones y cobros programados

CódigoHTTPReintentableQué hacer
SUBSCRIPTION_NOT_CHARGEABLE409noLa suscripción no se puede cobrar en su estado actual: tiene que estar activa o con cobro vencido.
MANDATE_NOT_ACTIVE409noEl cliente todavía no autorizó la domiciliación. Espera a que la apruebe en su banco.
CHARGE_NOT_FOUND404noNo existe ese cobro programado en esta suscripción.
CHARGE_NOT_CANCELABLE409noSolo se cancela un cobro programado que todavía no se intentó.
CHARGE_NOT_RETRYABLE409noSolo se reintenta un cobro que falló.
CHARGE_IN_PROGRESS409noEl cobro del plan se está procesando en el banco. Espera a que termine antes de marcarlo.
NOTHING_OWED409noLa suscripción está al día: no hay ningún cobro adeudado que marcar.
SUBSCRIPTION_NOT_METERED422noLa 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ódigoHTTPReintentableQué hacer
METER_NOT_FOUND404noNo existe ese medidor en este entorno.
METER_INACTIVE409noEl medidor está desactivado. Actívalo para registrar eventos.
METER_EVENT_NAME_TAKEN409noYa hay un medidor con ese nombre de evento en este entorno.
INVALID_BILLING_SCHEME400noEl esquema de cobro del precio no es válido para los campos que enviaste.
INVALID_TIERS400noLos tramos del precio no son válidos. El mensaje indica el motivo.
USAGE_EVENT_OUT_OF_RANGE400noLa 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ódigoHTTPReintentableQué hacer
ROLE_IN_USE409noEl rol todavía tiene miembros asignados. Reasigna antes a quienes lo tienen.
NOT_READY422noLa 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érminoQué significaEn la API
DomiciliaciónEl 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ónEl permiso que el cliente da para los cobros automáticos. En la consola se llama «Autorización».mandate, con mandateStatus
Número de contratoEl identificador de la autorización ante el banco. Empieza con PL.contractNumber, con contractDate
Aprobar en el bancoLo 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ónEl 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
ComercioQuien cobra. Es el «acreedor» solo cuando se explica un término del banco.—
ClienteQuien paga la suscripción y da la autorización. Es el «deudor» solo cuando se explica un término del banco.—
Enlace de contingenciaEl 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, TransferenciaMétodos de cobro con el banco. Se nombran igual en todas partes.immediate_debit, pago_movil, c2p, transfer
EnvíosPagos 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

  1. Responde 2xx antes de procesar. Si tardas, pagolink reintenta y el mismo id llega dos veces.
  2. Verifica la firma sobre el cuerpo crudo con X-Pagolink-Signature (ver Verificar la firma). Cualquier respuesta distinta de 2xx cuenta como fallo y se reintenta.
  3. Deduplica por id y responde 2xx también al repetido.
  4. Revisa la lista de entregas en la consola: cada intento, su código HTTP y la espera hasta el siguiente.
La infraestructura de pagos de Venezuela.
pagolink, un producto de SOFTBIZ