Crear Link de Pago
Crea un nuevo link de pago que puedes compartir con tu cliente por cualquier canal. Por defecto el link vence 3 días después de su creación; con expiresInDays puedes elegir una vigencia de 1 a 60 días.
Endpoint
POST /v1/payment-linksAutenticación
Requiere el header X-API-Key. Consulta la .
Funciona con la API key de la cuenta y con la API key de un proyecto. Si usas la de un proyecto, el link queda asociado a ese proyecto: sus webhooks de pago se envían primero al webhook del proyecto (ver ).
Cuerpo de la solicitud
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
concept | string | Sí | Descripción del cobro que verá el cliente |
amount | integer | Sí | Monto en centavos de USD (mín. 100, máx. 1000000) |
customerName | string | Sí | Nombre del cliente (1 a 120 caracteres) |
isTest | boolean | Sí | true para modo prueba (sandbox), false para producción |
customerEmail | string | No | Email válido del cliente. Se precarga en la página de pago |
requestCustomerEmail | boolean | No | Si la página de pago le pide el email al cliente. Default: true. No puede ser true cuando envías customerEmail |
redirectUrl | string | No | URL http o https a la que se redirige al cliente después del pago (máx. 2048 caracteres) |
metadata | object | No | Datos personalizados (máx. 20 llaves, máx. 8192 bytes) |
paymentMethods | array | No | Métodos de pago permitidos: "card", "yappy", "paypal". Sin duplicados, al menos 1 cuando se especifica |
expiresInDays | integer | No | Días que el link se puede pagar, de 1 a 60. Default: 3 días |
La API no acepta campos adicionales: un campo desconocido devuelve un . Los valores de texto se recortan (espacios al inicio y al final) antes de validarse.
Validación de campos
amount
El monto debe ser un entero en centavos dentro del rango 100 a 1000000:
| Monto | Equivalente | Resultado |
|---|---|---|
100 | $1.00 | Válido |
1500 | $15.00 | Válido |
1000000 | $10,000.00 | Válido |
50 | $0.50 | 400 con código ERR_AMOUNT_BELOW_NMI_MINIMUM |
0, negativos o decimales | — | 400 de validación |
1500000 | $15,000.00 | 400 de validación (máximo $10,000.00) |
Un monto de 1 a 99 centavos pasa la validación de campos, pero se rechaza con el error . Esa verificación ocurre después de las de proveedor activo y métodos de pago.
concept
Texto descriptivo que el cliente verá en la página de pago. Campo requerido, no puede estar vacío.
customerName
Nombre del cliente. Campo requerido, de 1 a 120 caracteres.
customerEmail
Email válido opcional. Se precarga en el campo de email de la página de pago.
redirectUrl
URL válida a donde redirigir al cliente después de completar el pago. Solo se aceptan los esquemas http:// y https:// (recomendamos https). Máximo 2048 caracteres.
requestCustomerEmail
- Default:
true(la página de pago pide el email al cliente) - Enviar
requestCustomerEmail: truejunto concustomerEmailse rechaza con un error de validación - Si envías
customerEmaily omitesrequestCustomerEmail, el link se guarda conrequestCustomerEmail: truey la página sigue pidiendo el email (precargado). EnvíarequestCustomerEmail: falsepara omitir ese paso
metadata
Objeto de datos personalizados con las siguientes restricciones:
- Máximo 20 llaves, y no puede ser un objeto vacío
- Llaves de 1 a 40 caracteres
- Valores de tipo
string(máx. 255 caracteres),number,booleanonull - Máximo 8192 bytes al serializarse como JSON
La metadata se devuelve en las respuestas del link y en los webhooks de pago (payment.success y payment.failed).
paymentMethods
Array de métodos de pago permitidos para este link:
- Valores permitidos:
"card","yappy","paypal" - No se permiten duplicados y debe contener al menos 1 elemento cuando se especifica
- Cada método debe estar activo en tu cuenta para el entorno del link (
isTest: trueusa tus proveedores de sandbox;isTest: false, los de producción). Si no, la solicitud se rechaza conERR_INACTIVE_PAYMENT_METHOD - Si no se incluye, la página de pago ofrece todos los métodos activos de tu cuenta para ese entorno
| Valor | Lo que ve el cliente |
|---|---|
"card" | Formulario de tarjeta de crédito o débito |
"yappy" | Pago con Yappy |
"paypal" | Botones de PayPal: pagar con su cuenta de PayPal o con tarjeta de débito o crédito sin cuenta de PayPal |
PayPal solo en links de pago únicos
Los links creados con POST /v1/payment-links son links de pago únicos, así que admiten "paypal". PayPal no puede guardar una tarjeta para cobros posteriores, por eso no se ofrece en los links de colección de la . Consulta .
expiresInDays
- Entero (días completos;
1.5o"7"se rechazan) - Rango: de
1a60 - Si se omite, el link vence 3 días después de crearse
expiresAten la respuesta es la fecha de creación másexpiresInDays× 24 horas
Después de expiresAt el link ya no se puede pagar: los intentos de pago se rechazan. Zelta Pay no marca los links como vencidos en ese momento ni envía un webhook: el estado guardado cambia a expired la primera vez que un listado (GET /v1/payment-links o el dashboard) devuelve el link, y esa misma respuesta todavía lo muestra como pending. devuelve el estado guardado, así que puede seguir mostrando pending después de expiresAt. Para saber si un link todavía se puede pagar, compara expiresAt con la hora actual.
Vigencia en el dashboard
Los links creados desde el tienen una vigencia fija de 7 días; expiresInDays solo existe en la API.
Ejemplos de solicitud
Solicitud básica
curl -X POST "https://api-pay.zelta.dev/v1/payment-links" \
-H "X-API-Key: tu-api-key-aqui" \
-H "Content-Type: application/json" \
-d '{
"concept": "Pago de servicio mensual",
"amount": 5000,
"customerName": "Maria Garcia",
"isTest": false
}'Solicitud con metadata y opciones
curl -X POST "https://api-pay.zelta.dev/v1/payment-links" \
-H "X-API-Key: tu-api-key-aqui" \
-H "Content-Type: application/json" \
-d '{
"concept": "Orden #ORD-2026-0042",
"amount": 15000,
"customerName": "Carlos Lopez",
"customerEmail": "[email protected]",
"requestCustomerEmail": false,
"isTest": false,
"redirectUrl": "https://mitienda.com/orden/confirmacion",
"paymentMethods": ["card", "yappy"],
"metadata": {
"orderId": "ORD-2026-0042",
"productName": "Plan Premium",
"userId": "usr_abc123",
"source": "checkout-web"
}
}'Link con PayPal
Ofrece tarjeta y PayPal en el mismo link. Ambos métodos deben estar activos en tu cuenta para el entorno del link.
curl -X POST "https://api-pay.zelta.dev/v1/payment-links" \
-H "X-API-Key: tu-api-key-aqui" \
-H "Content-Type: application/json" \
-d '{
"concept": "Orden #12345",
"amount": 4999,
"customerName": "Maria Lopez",
"isTest": true,
"paymentMethods": ["card", "paypal"]
}'Link con vigencia personalizada
Mantén el link disponible durante 60 días en lugar de los 3 por defecto (por ejemplo, un link adjunto a una factura).
curl -X POST "https://api-pay.zelta.dev/v1/payment-links" \
-H "X-API-Key: tu-api-key-aqui" \
-H "Content-Type: application/json" \
-d '{
"concept": "Factura #2026-0042",
"amount": 125000,
"customerName": "Acme Corp",
"isTest": false,
"expiresInDays": 60
}'Formato de respuesta
Una solicitud exitosa devuelve un status 201 Created:
{
"success": true,
"data": {
"paymentLink": {
"id": "7312345678901234567",
"paymentLinkUrl": "https://pay.zelta.dev/Xk3v9Q_aB7mN2pL8rT4wYz1c",
"customerName": "Carlos Lopez",
"customerEmail": "[email protected]",
"concept": "Orden #ORD-2026-0042",
"amount": 15000,
"createdAt": "2026-03-13T10:30:00.000Z",
"expiresAt": "2026-03-16T10:30:00.000Z",
"status": "pending",
"isTest": false,
"redirectUrl": "https://mitienda.com/orden/confirmacion",
"metadata": {
"orderId": "ORD-2026-0042",
"productName": "Plan Premium",
"userId": "usr_abc123",
"source": "checkout-web"
},
"paymentMethods": ["card", "yappy"],
"requestCustomerEmail": false
}
},
"message": "Payment link created successfully",
"timestamp": "2026-03-13T10:30:00.000Z"
}Campos de la respuesta
| Campo | Tipo | Descripción |
|---|---|---|
id | string | Identificador único del link de pago (cadena numérica) |
paymentLinkUrl | string | URL de la página de pago. Su último segmento es el hashUrl de 24 caracteres que usan y |
customerName | string | Nombre del cliente |
customerEmail | string | Email del cliente. Se omite si no se envió |
concept | string | Concepto o descripción del cobro |
amount | integer | Monto en centavos |
createdAt | string | Fecha de creación (ISO 8601) |
expiresAt | string | Fecha de vencimiento (ISO 8601): expiresInDays días después de la creación, o 3 días si no se envió |
status | string | Estado del link. Siempre "pending" al crearse |
isTest | boolean | Si es un link de prueba |
redirectUrl | string | URL de redirección después del pago. Se omite si no se envió |
metadata | object | Datos personalizados asociados al link. Se omite si no se envió |
paymentMethods | array | Métodos de pago permitidos. Se omite si no se especificaron (se ofrecen todos los métodos activos) |
requestCustomerEmail | boolean | Si la página de pago pide el email al cliente |
completedAt | string | Fecha en que se completó el pago (ISO 8601). Se omite mientras no exista |
cancelledAt | string | Fecha de cancelación (ISO 8601). Se omite mientras no exista |
Los campos opcionales se omiten cuando no tienen valor; nunca llegan como null.
Códigos de estado
| Código | Descripción |
|---|---|
201 | Link de pago creado exitosamente |
400 | Error de validación, monto menor a 100 centavos, método de pago inactivo o sin proveedor de pago activo |
401 | Falta el header X-API-Key (ERR_API_KEY_MISSING) |
403 | Cuenta suspendida o, con una API key de proyecto, proyecto inactivo |
404 | API key desconocida (ERR_ACCOUNT_API_KEY_NOT_FOUND) o cuenta no encontrada |
429 | Rate limit excedido (cuerpo en texto plano; espera Retry-After segundos) |
Respuestas de error
Error de validación
Los campos inválidos o faltantes devuelven 400 con el resultado de la validación en lugar del formato de error habitual: error.issues lista cada campo que falló (path) con un message legible y un code. No hay message, error.code ni timestamp en la raíz.
{
"success": false,
"error": {
"issues": [
{
"code": "too_small",
"minimum": 0,
"type": "number",
"inclusive": false,
"exact": false,
"message": "Amount must be a positive integer",
"path": ["amount"]
}
],
"name": "ZodError"
}
}La validación cruzada (customerEmail junto con requestCustomerEmail: true) normalmente se reporta junto con los demás issues, pero algunos errores de campo impiden que se ejecute (por ejemplo, un campo requerido faltante o un valor inválido en paymentMethods). En ese caso puede aparecer en una solicitud posterior, después de corregirlos.
Campos requeridos faltantes
Un campo faltante se reporta como "Required"; un concept vacío, como "Concept is required".
{
"success": false,
"error": {
"issues": [
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": ["concept"],
"message": "Required"
}
],
"name": "ZodError"
}
}Monto inválido
{
"success": false,
"error": {
"issues": [
{
"code": "too_big",
"maximum": 1000000,
"type": "number",
"inclusive": true,
"exact": false,
"message": "Amount must be less than 10,000.00 USD (or 1,000,000 cents)",
"path": ["amount"]
}
],
"name": "ZodError"
}
}Error de validación de metadata
{
"success": false,
"error": {
"issues": [
{
"code": "custom",
"message": "Metadata cannot have more than 20 keys",
"path": ["metadata"]
}
],
"name": "ZodError"
}
}Otros mensajes posibles: "Metadata cannot be an empty object", "Metadata is too large" (más de 8192 bytes) y "Metadata key must be less than 40 characters".
expiresInDays inválido
Se devuelve cuando expiresInDays es menor que 1, mayor que 60, no es un número entero ("expiresInDays must be an integer") o no es un número:
{
"success": false,
"error": {
"issues": [
{
"code": "too_big",
"maximum": 60,
"type": "number",
"inclusive": true,
"exact": false,
"message": "expiresInDays must be between 1 and 60",
"path": ["expiresInDays"]
}
],
"name": "ZodError"
}
}Campo desconocido
{
"success": false,
"error": {
"issues": [
{
"code": "unrecognized_keys",
"keys": ["currency"],
"path": [],
"message": "Unrecognized key(s) in object: 'currency'"
}
],
"name": "ZodError"
}
}Monto por debajo del mínimo
Un amount de 1 a 99 centavos pasa la validación de campos, pero se rechaza con un error con código en el formato habitual. Esta verificación ocurre después de las de proveedor activo y métodos de pago, así que esos errores tienen prioridad.
{
"success": false,
"message": "El monto mínimo para un link de pago es 1.00 USD (100 centavos); no se puede generar el link de pago",
"error": {
"code": "ERR_AMOUNT_BELOW_NMI_MINIMUM"
},
"timestamp": "2026-03-13T10:30:00.000Z"
}Con una API key de proyecto, además se envía el webhook al webhook del proyecto (si tiene uno activo).
Sin proveedor de pagos activo
Se devuelve cuando no hay un proveedor de pago activo para el entorno del link: un link con isTest: true necesita un proveedor de sandbox activo; uno con isTest: false, un proveedor de producción activo.
{
"success": false,
"message": "No active payment provider found",
"error": {
"code": "ERR_NO_ACTIVE_PAYMENT_PROVIDER"
},
"timestamp": "2026-03-13T10:30:00.000Z"
}Método de pago inactivo
El mensaje lista los métodos que no están activos para el entorno del link:
{
"success": false,
"message": "Payment method(s) not active: paypal",
"error": {
"code": "ERR_INACTIVE_PAYMENT_METHOD"
},
"timestamp": "2026-03-13T10:30:00.000Z"
}Ejemplos de implementación
Creación básica con manejo de errores
async function createPaymentLink(data) {
const response = await fetch('https://api-pay.zelta.dev/v1/payment-links', {
method: 'POST',
headers: {
'X-API-Key': process.env.ZELTA_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (response.status === 429) {
// Rate limit: cuerpo en texto plano, espera Retry-After segundos
return { success: false, error: { code: 'RATE_LIMITED', retryAfter: response.headers.get('Retry-After') } };
}
const result = await response.json();
if (!result.success) {
if (result.error?.issues) {
// Error de validación: un issue por campo
result.error.issues.forEach(i => console.error(` - ${i.path.join('.')}: ${i.message}`));
return { success: false, error: { code: 'VALIDATION_ERROR', issues: result.error.issues } };
}
console.error('Error de API:', result.error.code, result.message);
return { success: false, error: { code: result.error.code, message: result.message } };
}
return { success: true, paymentLink: result.data.paymentLink };
}E-commerce: link para una orden
async function createOrderPaymentLink(order) {
const result = await createPaymentLink({
concept: `Orden #${order.id} - ${order.items.length} productos`,
amount: order.totalInCents,
customerName: order.customerName,
customerEmail: order.customerEmail,
requestCustomerEmail: false,
isTest: false,
redirectUrl: `https://mitienda.com/ordenes/${order.id}/confirmacion`,
paymentMethods: ['card', 'yappy', 'paypal'],
expiresInDays: 2,
metadata: {
orderId: order.id,
itemCount: order.items.length,
source: 'web-checkout'
}
});
if (!result.success) {
throw new Error(`Error al crear link: ${result.error.code}`);
}
return result.paymentLink;
}Suscripciones: link de pago periódico
async function createSubscriptionPaymentLink(subscription) {
const monthNames = [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
];
const now = new Date();
const monthName = monthNames[now.getMonth()];
return await createPaymentLink({
concept: `${subscription.planName} - ${monthName} ${now.getFullYear()}`,
amount: subscription.amountInCents,
customerName: subscription.customerName,
customerEmail: subscription.customerEmail,
requestCustomerEmail: false,
isTest: false,
redirectUrl: `https://miapp.com/suscripciones/${subscription.id}`,
expiresInDays: 10, // el cliente tiene 10 días para pagar el período
metadata: {
subscriptionId: subscription.id,
planName: subscription.planName,
billingPeriod: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`,
type: 'subscription-renewal'
}
});
}Helpers de validación
Valida los datos antes de enviar la solicitud para evitar errores:
function validateAmount(amount) {
if (!Number.isInteger(amount)) {
return { valid: false, error: 'El monto debe ser un número entero de centavos' };
}
if (amount < 100 || amount > 1000000) {
return { valid: false, error: 'El monto debe estar entre 100 y 1000000 centavos ($1.00 - $10,000.00)' };
}
return { valid: true };
}
function validateMetadata(metadata) {
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
return { valid: false, error: 'La metadata debe ser un objeto' };
}
const keys = Object.keys(metadata);
if (keys.length === 0) {
return { valid: false, error: 'La metadata no puede ser un objeto vacío' };
}
if (keys.length > 20) {
return { valid: false, error: `La metadata tiene ${keys.length} llaves (máx: 20)` };
}
for (const [key, value] of Object.entries(metadata)) {
if (key.length < 1 || key.length > 40) {
return { valid: false, error: `La llave "${key}" debe tener de 1 a 40 caracteres` };
}
const ok = value === null || ['number', 'boolean'].includes(typeof value) ||
(typeof value === 'string' && value.length <= 255);
if (!ok) {
return { valid: false, error: `Valor inválido en "${key}" (string de hasta 255, number, boolean o null)` };
}
}
const size = new TextEncoder().encode(JSON.stringify(metadata)).length;
if (size > 8192) {
return { valid: false, error: `La metadata ocupa ${size} bytes (máx: 8192)` };
}
return { valid: true };
}
function validateExpiresInDays(days) {
if (days === undefined) return { valid: true }; // default: 3 días
if (!Number.isInteger(days) || days < 1 || days > 60) {
return { valid: false, error: 'expiresInDays debe ser un entero entre 1 y 60' };
}
return { valid: true };
}Buenas prácticas
Manejo de errores
Siempre verifica el campo success y maneja los errores de forma apropiada: usa error.code en los errores de negocio y error.issues en los de validación.
Modo de prueba
Usa isTest: true durante el desarrollo. Los links de prueba se cobran con las credenciales de sandbox de tus métodos de pago, así que no mueven dinero real.
No uses modo prueba con clientes reales
Nunca envíes a clientes reales un link con isTest: true: se cobra contra el entorno de sandbox y no genera cobros reales.
Estrategia de metadata
- Incluye siempre un identificador único de tu sistema (ej.
orderId) para relacionar el webhook con tu orden - Agrega el contexto del pago (ej.
source,type) - No almacenes datos sensibles (tarjetas, contraseñas, tokens)
Vigencia del link
- Elige
expiresInDayssegún cuánto tiempo necesita tu cliente para pagar (por ejemplo, 1 día para un checkout, 30 días para una factura) - Si un link vence sin pago, crea uno nuevo: no se puede extender la vigencia de un link existente
Lógica de reintentos
Reintenta solo errores de red y 5xx. No reintentes errores 4xx, y después de un 429 espera los segundos que indica Retry-After.
Cada reintento puede crear otro link
POST /v1/payment-links no tiene clave de idempotencia: si reintentas una solicitud que sí se procesó (por ejemplo, tras un timeout de red), se crea un segundo link. Antes de reintentar, puedes y buscar el tuyo por la metadata que enviaste.
async function createPaymentLinkWithRetry(data, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch('https://api-pay.zelta.dev/v1/payment-links', {
method: 'POST',
headers: {
'X-API-Key': process.env.ZELTA_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get('Retry-After') ?? 60);
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
continue;
}
// No reintentar otros errores 4xx
if (response.status >= 400 && response.status < 500) {
const result = await response.json();
return { success: false, error: result.error };
}
const result = await response.json();
if (result.success) {
return { success: true, paymentLink: result.data.paymentLink };
}
} catch (error) {
if (attempt === maxRetries - 1) {
throw error;
}
}
// Backoff exponencial: 1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
}Rate limiting
La API admite 60 solicitudes por ventana de 60 segundos por API key. Si necesitas crear muchos links, espacia las solicitudes para no exceder el límite. Consulta la para más detalles.
Siguientes pasos
- — Consulta el estado de un link
- — Cancela un link pendiente
- — Obtén todos tus links
- — Recibe notificaciones cuando se completan los pagos
- — Ejemplos prácticos de integración