Links de Pago
Los links de pago son la funcionalidad principal de Zelta Pay. Te permiten crear URLs de cobro que puedes compartir con tus clientes por cualquier canal: WhatsApp, correo electrónico, redes sociales o integrado directamente en tu sitio web. Tu cliente abre el link y paga con tarjeta, Yappy o PayPal, según los métodos que tengas activos.
Crear un link de pago
Para crear un link de pago, realiza una solicitud POST al endpoint /v1/payment-links. Consulta la para ver todas las reglas de validación y los errores.
Campos requeridos
| Campo | Tipo | Descripción |
|---|---|---|
concept | string | Descripción del cobro que verá el cliente |
amount | integer | Monto en centavos de USD (mín. 100, máx. 1000000). Ej: 1500 = $15.00 |
customerName | string | Nombre del cliente (1 a 120 caracteres) |
isTest | boolean | true para modo prueba (sandbox), false para producción |
Campos opcionales
| Campo | Tipo | Descripción |
|---|---|---|
customerEmail | string | Email válido del cliente. Se precarga en la página de pago |
requestCustomerEmail | boolean | Si la página de pago pide el email al cliente. Default: true. No puede ser true cuando se proporciona customerEmail; envía false para omitir el paso del email |
redirectUrl | string | URL http o https a donde redirigir después del pago (máx. 2048 caracteres) |
metadata | object | Datos personalizados (máx. 20 llaves de 1 a 40 caracteres, valores string de hasta 255 caracteres, number, boolean o null, máx. 8192 bytes) |
paymentMethods | array | Métodos de pago permitidos: "card", "yappy", "paypal". Sin duplicados, al menos 1 cuando se especifica. Si se omite, se ofrecen todos los métodos activos |
expiresInDays | integer | Días que el link se puede pagar, de 1 a 60. Default: 3 días |
Montos en centavos
Todos los montos en Zelta Pay se expresan en centavos. Para cobrar $25.00, envía amount: 2500. El rango permitido es de $1.00 (100 centavos) a $10,000.00 (1000000 centavos).
Ejemplo básico
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
}'Ejemplo con metadata
La metadata te permite asociar información personalizada al link de pago. Es ideal para vincular pagos con registros en tu sistema.
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]",
"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"
}
}'Respuesta exitosa
Cuando el link se crea correctamente, recibes un 201 Created con los detalles del link:
{
"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": true
}
},
"message": "Payment link created successfully",
"timestamp": "2026-03-13T10:30:00.000Z"
}Campos de la respuesta
| Campo | Descripción |
|---|---|
id | Identificador único del link de pago (cadena numérica) |
paymentLinkUrl | URL que debes compartir con tu cliente para que realice el pago. Su último segmento es el hashUrl que usas para o el link |
status | Estado actual del link. Siempre "pending" al crearse |
createdAt | Fecha y hora de creación en formato ISO 8601 |
expiresAt | Fecha de vencimiento: 3 días después de la creación, o los días de expiresInDays |
paymentMethods | Métodos de pago permitidos (se omite si no los especificaste) |
requestCustomerEmail | Si la página de pago pedirá el email al cliente. En el ejemplo es true porque se envió customerEmail sin requestCustomerEmail: false: la página pide el email, precargado |
Los campos opcionales (customerEmail, redirectUrl, metadata, paymentMethods, completedAt, cancelledAt) se omiten cuando no tienen valor.
Vigencia del link
Un link creado por API vence 3 días después de crearse. Para elegir otra vigencia, envía expiresInDays con un entero de 1 a 60:
{
"concept": "Factura #2026-0042",
"amount": 125000,
"customerName": "Acme Corp",
"isTest": false,
"expiresInDays": 30
}- Después de
expiresAtel link ya no se puede pagar: la página de pago muestra Link Expirado - No se envía ningún webhook cuando un link vence
- El estado guardado cambia a
expiredla primera vez que un devuelve el link, así que vencido puede seguir mostrandopending: comparaexpiresAtcon la hora actual - Los links creados desde el dashboard tienen una vigencia fija de 7 días
Estados del link
| Estado | Significado |
|---|---|
pending | Esperando el pago |
processing | Hay un pago en confirmación (con PayPal puede durar hasta varios días) |
completed | Pago completado. Recibes el webhook payment.success |
cancelled | Link cancelado (anulado) |
expired | Link vencido sin pago, guardado como expirado |
Validaciones
Helpers de validación
Antes de enviar la solicitud a la API, puedes validar los datos en tu aplicación para evitar errores:
// Validar monto (100 a 1000000 centavos)
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 };
}
// Validar metadata (1 a 20 llaves, max 8192 bytes)
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 || keys.length > 20) {
return { valid: false, error: `La metadata debe tener de 1 a 20 llaves (tiene ${keys.length})` };
}
const size = new TextEncoder().encode(JSON.stringify(metadata)).length;
if (size > 8192) {
return { valid: false, error: `La metadata ocupa ${size} bytes (max: 8192)` };
}
return { valid: true };
}
// Validar métodos de pago
function validatePaymentMethods(methods) {
const allowed = ['card', 'yappy', 'paypal'];
const unique = new Set(methods);
if (methods.length === 0) {
return { valid: false, error: 'Incluye al menos un método de pago' };
}
if (unique.size !== methods.length) {
return { valid: false, error: 'No se permiten métodos de pago duplicados' };
}
for (const method of methods) {
if (!allowed.includes(method)) {
return { valid: false, error: `Método de pago no válido: ${method}` };
}
}
return { valid: true };
}Códigos de error
Cuando la solicitud falla, la API devuelve un error. Los errores de negocio traen un código en error.code:
| Código | Descripción | Solución |
|---|---|---|
ERR_NO_ACTIVE_PAYMENT_PROVIDER | No tienes un proveedor de pagos activo en el entorno del link | Configura al menos un proveedor en para ese entorno (sandbox o producción) |
ERR_INACTIVE_PAYMENT_METHOD | Un método de paymentMethods no está activo en el entorno del link | Verifica que los métodos estén habilitados en tu cuenta para ese entorno |
ERR_AMOUNT_BELOW_NMI_MINIMUM | El monto es de 1 a 99 centavos | El mínimo de un link es 100 centavos ($1.00) |
Los errores de validación (campos faltantes, montos fuera de rango, metadata inválida, expiresInDays fuera de rango, campos desconocidos) no traen error.code: traen la lista error.issues con el campo (path) y el motivo (message) de cada problema.
Ejemplo de respuesta de error
{
"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"
}
}Casos de uso
E-commerce: link de pago para una orden
async function createOrderPaymentLink(order) {
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({
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'
}
})
});
const result = await response.json();
if (!result.success) {
throw new Error(`Error al crear link: ${result.error.code ?? JSON.stringify(result.error.issues)}`);
}
return result.data.paymentLink;
}Suscripciones: link de pago recurrente
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()];
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({
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,
metadata: {
subscriptionId: subscription.id,
planName: subscription.planName,
billingPeriod: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`,
type: 'subscription-renewal'
}
})
});
return await response.json();
}Cobros recurrentes automáticos
Si quieres cobrar automáticamente cada período con la tarjeta guardada del cliente, en lugar de enviarle un link cada mes, usa las .
Buenas prácticas
Manejo de errores
Siempre envuelve las llamadas a la API en un bloque try/catch y maneja los errores de forma adecuada:
async function createPaymentLinkSafe(data) {
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) {
// 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) {
console.error('Error de API:', result.error.code ?? 'VALIDATION', result.message ?? result.error.issues);
return { success: false, error: result.error };
}
return { success: true, paymentLink: result.data.paymentLink };
} catch (error) {
console.error('Error de red:', error.message);
return { success: false, error: { code: 'NETWORK_ERROR', message: error.message } };
}
}Modo de prueba
Usa siempre 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
Usa la metadata para vincular pagos con tu sistema interno. Algunas recomendaciones:
- Incluye siempre un identificador único de tu sistema (ej.
orderId,subscriptionId): llega en el webhookpayment.success - Agrega el contexto del pago (ej.
source,type) - No almacenes datos sensibles (tarjetas, contraseñas, tokens)
Lógica de reintentos
Si la API devuelve un error de red o un código 5xx, implementa reintentos con backoff exponencial. Ten en cuenta que POST /v1/payment-links no tiene clave de idempotencia: si una solicitud se procesó pero no recibiste la respuesta, reintentarla crea un segundo link.
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 (son errores del cliente)
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 múltiples links de forma masiva, espacia las solicitudes para no exceder el límite. Consulta la para más detalles.
Siguientes pasos
- — Referencia completa del endpoint
- — Ofrece PayPal en tus links
- — Recibe notificaciones cuando se completan los pagos
- — Ejemplos prácticos de integración
- — API keys y rate limiting
- — Documentación completa de endpoints