Casos de Uso
Esta guía presenta ejemplos prácticos de cómo integrar Zelta Pay en diferentes escenarios de negocio. Cada caso de uso incluye código funcional que puedes adaptar a tu aplicación.
E-commerce
Links de pago para productos
Crea links de pago vinculados a productos de tu catálogo:
async function createProductPaymentLink(product, customer) {
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: `Compra: ${product.name}`,
amount: product.priceInCents,
customerName: customer.name,
customerEmail: customer.email,
isTest: false,
redirectUrl: `https://mitienda.com/compra/gracias?product=${product.id}`,
paymentMethods: ['card', 'yappy'],
metadata: {
productId: product.id,
productName: product.name,
sku: product.sku,
customerId: customer.id,
type: 'product-purchase'
}
})
});
return await response.json();
}
// Uso
const paymentLink = await createProductPaymentLink(
{ id: 'prod_001', name: 'Audífonos Bluetooth', priceInCents: 4500, sku: 'AUD-BT-001' },
{ id: 'cust_123', name: 'Ana Martinez', email: '[email protected]' }
);Checkout de carrito de compras
Genera un link de pago consolidado para un carrito completo:
async function createCartCheckout(cart, customer) {
const itemsSummary = cart.items
.map(item => `${item.name} x${item.quantity}`)
.join(', ');
const totalCents = cart.items.reduce(
(sum, item) => sum + (item.priceInCents * item.quantity), 0
);
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 - ${cart.items.length} productos`,
amount: totalCents,
customerName: customer.name,
customerEmail: customer.email,
isTest: false,
redirectUrl: `https://mitienda.com/ordenes/${cart.id}/confirmacion`,
metadata: {
cartId: cart.id,
itemCount: String(cart.items.length),
itemsSummary: itemsSummary.substring(0, 200),
customerId: customer.id,
type: 'cart-checkout'
}
})
});
return await response.json();
}Seguimiento de estado de órdenes
Consulta el estado de un link de pago para actualizar tu sistema. Usa el hashUrl del link (el último segmento de paymentLinkUrl), y recuerda que un link vencido puede seguir apareciendo como pending hasta que un listado lo marque como expired:
async function checkOrderPaymentStatus(hashUrl) {
const response = await fetch(
`https://api-pay.zelta.dev/v1/payment-links/${hashUrl}`,
{
headers: {
'X-API-Key': process.env.ZELTA_API_KEY,
'Content-Type': 'application/json'
}
}
);
const result = await response.json();
if (!result.success) {
throw new Error(`Error al consultar link: ${result.error.code}`);
}
const paymentLink = result.data.paymentLink;
const isPastExpiry = new Date(paymentLink.expiresAt) <= new Date();
const status = paymentLink.status === 'pending' && isPastExpiry ? 'expired' : paymentLink.status;
switch (status) {
case 'pending':
console.log('Pago pendiente - el cliente aún no ha pagado');
break;
case 'processing':
console.log('Pago en confirmación - espera el webhook payment.success');
break;
case 'completed':
console.log('Pago completado - actualizar orden como pagada');
await updateOrderStatus(paymentLink.metadata?.orderId, 'paid');
break;
case 'expired':
console.log('Link expirado - notificar al cliente');
await notifyCustomerExpiredLink(paymentLink.metadata?.customerId);
break;
case 'cancelled':
console.log('Link cancelado');
break;
}
return paymentLink;
}Suscripciones
Pagos mensuales
Genera links de pago mensuales para tus suscriptores. Si quieres que el cobro se haga automáticamente con la tarjeta guardada del cliente, usa en su lugar las .
async function createMonthlySubscriptionPayment(subscription) {
const now = new Date();
const monthNames = [
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
];
const period = `${monthNames[now.getMonth()]} ${now.getFullYear()}`;
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} - ${period}`,
amount: subscription.amountInCents,
customerName: subscription.customerName,
customerEmail: subscription.customerEmail,
isTest: false,
redirectUrl: `https://miapp.com/suscripciones/${subscription.id}`,
metadata: {
subscriptionId: subscription.id,
planName: subscription.planName,
billingPeriod: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`,
type: 'subscription-renewal'
}
})
});
return await response.json();
}
// Generar links para todas las suscripciones activas
async function generateMonthlyBilling(subscriptions) {
const results = [];
for (const sub of subscriptions) {
const result = await createMonthlySubscriptionPayment(sub);
results.push({
subscriptionId: sub.id,
paymentLinkUrl: result.data?.paymentLink?.paymentLinkUrl,
success: result.success
});
// Respetar rate limit (60 req/min)
await new Promise(resolve => setTimeout(resolve, 1100));
}
return results;
}Seguimiento de renovaciones
Lleva un control de qué suscripciones han pagado y cuáles necesitan seguimiento:
async function trackSubscriptionRenewals(subscriptions, paymentLinks) {
const renewalStatus = [];
for (const sub of subscriptions) {
const link = paymentLinks.find(
pl => pl.metadata?.subscriptionId === sub.id
);
if (!link) {
renewalStatus.push({
subscriptionId: sub.id,
status: 'no-link',
action: 'Crear link de pago'
});
continue;
}
const isPastExpiry = new Date(link.expiresAt) <= new Date();
const status = link.status === 'pending' && isPastExpiry ? 'expired' : link.status;
switch (status) {
case 'completed':
renewalStatus.push({
subscriptionId: sub.id,
status: 'paid',
action: 'Ninguna - renovación exitosa'
});
break;
case 'pending':
const expiresAt = new Date(link.expiresAt);
const hoursLeft = (expiresAt - new Date()) / (1000 * 60 * 60);
renewalStatus.push({
subscriptionId: sub.id,
status: 'pending',
action: hoursLeft < 24
? 'Enviar recordatorio urgente'
: 'Esperar pago del cliente'
});
break;
case 'expired':
renewalStatus.push({
subscriptionId: sub.id,
status: 'expired',
action: 'Generar nuevo link y notificar al cliente'
});
break;
}
}
return renewalStatus;
}Cobro de servicios
Consultorías y servicios profesionales
async function createServicePaymentLink(service) {
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: `${service.type}: ${service.description}`,
amount: service.amountInCents,
customerName: service.clientName,
customerEmail: service.clientEmail,
isTest: false,
redirectUrl: service.confirmationUrl,
metadata: {
serviceId: service.id,
serviceType: service.type,
professionalId: service.professionalId,
scheduledDate: service.date,
type: 'service-payment'
}
})
});
return await response.json();
}
// Ejemplo: cobro de consultoría
const result = await createServicePaymentLink({
id: 'srv_001',
type: 'Consultoría legal',
description: 'Sesión de 1 hora - Derecho corporativo',
amountInCents: 150000,
clientName: 'Roberto Diaz',
clientEmail: '[email protected]',
professionalId: 'prof_045',
date: '2026-03-20',
confirmationUrl: 'https://miconsultorio.com/citas/confirmacion'
});Registro a eventos
async function createEventRegistrationLink(event, attendee) {
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: `Registro: ${event.name}`,
amount: event.ticketPriceInCents,
customerName: attendee.name,
customerEmail: attendee.email,
isTest: false,
redirectUrl: `https://mieventos.com/registro/${event.id}/confirmacion`,
paymentMethods: ['card', 'yappy'],
metadata: {
eventId: event.id,
eventName: event.name,
eventDate: event.date,
ticketType: attendee.ticketType,
attendeeId: attendee.id,
type: 'event-registration'
}
})
});
return await response.json();
}Integración con webhooks
Combina la creación de links de pago con para automatizar tu flujo completo.
Handler de pago exitoso
// Procesar webhook de pago completado
app.post('/webhook/zelta-pay', express.raw({ type: 'application/json' }), async (req, res) => {
const payload = JSON.parse(req.body);
if (payload.type === 'payment.success') {
const { metadata } = payload; // se omite si el link no tiene metadata
switch (metadata?.type) {
case 'product-purchase':
await fulfillProductOrder(metadata.productId, metadata.customerId);
await sendOrderConfirmationEmail(metadata.customerId);
break;
case 'cart-checkout':
await processCartOrder(metadata.cartId);
await updateInventory(metadata.cartId);
await sendOrderConfirmationEmail(metadata.customerId);
break;
case 'subscription-renewal':
await renewSubscription(metadata.subscriptionId, metadata.billingPeriod);
await sendRenewalConfirmationEmail(metadata.subscriptionId);
break;
case 'service-payment':
await confirmServiceAppointment(metadata.serviceId);
await notifyProfessional(metadata.professionalId);
break;
case 'event-registration':
await confirmEventRegistration(metadata.eventId, metadata.attendeeId);
await sendEventTicket(metadata.attendeeId, metadata.eventId);
break;
default:
console.log('Tipo de pago no reconocido:', metadata?.type);
}
}
res.status(200).json({ received: true });
});Handler de renovación de suscripción
async function handleSubscriptionRenewalWebhook(payload) {
const { metadata, transaction, customer } = payload;
if (metadata?.type !== 'subscription-renewal') return;
const subscriptionId = metadata.subscriptionId;
const billingPeriod = metadata.billingPeriod;
// 1. Extender la suscripción
await db.subscription.update({
where: { id: subscriptionId },
data: {
status: 'active',
currentPeriodEnd: getNextPeriodEnd(billingPeriod),
lastPaymentDate: new Date()
}
});
// 2. Registrar el pago
await db.payment.create({
data: {
subscriptionId,
amount: transaction.amount,
billingPeriod,
paymentLinkId: payload.paymentLinkId,
zeltaOrderId: transaction.orderId,
paidAt: new Date(transaction.paidAt)
}
});
// 3. Notificar al cliente
await sendEmail({
to: customer.customerEmail,
subject: 'Renovación exitosa',
body: `Tu suscripción ha sido renovada para ${billingPeriod}.`
});
}Analítica
Analítica de pagos
Consulta tus links de pago para generar reportes. La API no filtra por fecha, así que recorre las páginas y filtra por createdAt en tu aplicación (ver ):
async function getPaymentAnalytics(startDate, endDate) {
const from = new Date(startDate);
const to = new Date(endDate);
const now = new Date();
// getAllPaymentLinks: recorre todas las páginas de GET /v1/payment-links
const links = (await getAllPaymentLinks()).filter(l => {
const createdAt = new Date(l.createdAt);
return !l.isTest && createdAt >= from && createdAt <= to;
});
const isExpired = l =>
l.status === 'expired' || (l.status === 'pending' && new Date(l.expiresAt) <= now);
const analytics = {
total: links.length,
completed: links.filter(l => l.status === 'completed').length,
pending: links.filter(l => l.status === 'pending' && !isExpired(l)).length,
expired: links.filter(isExpired).length,
totalAmountCollected: links
.filter(l => l.status === 'completed')
.reduce((sum, l) => sum + l.amount, 0),
conversionRate: 0,
averageAmount: 0
};
if (analytics.total > 0) {
analytics.conversionRate = (analytics.completed / analytics.total * 100).toFixed(2);
}
if (analytics.completed > 0) {
analytics.averageAmount = Math.round(analytics.totalAmountCollected / analytics.completed);
}
// El método con que se pagó cada link llega en el webhook payment.success
// (transaction.paymentMethod y transaction.paymentProvider); guárdalo ahí
// si necesitas un desglose por método.
return analytics;
}Historial de pagos por cliente
El parámetro search busca por una parte del nombre del cliente. Si guardas tu propio ID de cliente en la metadata, filtra además por ese valor:
async function getCustomerPaymentHistory(customerName, customerId) {
const response = await fetch(
`https://api-pay.zelta.dev/v1/payment-links?search=${encodeURIComponent(customerName)}&lim=100`,
{
headers: {
'X-API-Key': process.env.ZELTA_API_KEY,
'Content-Type': 'application/json'
}
}
);
const result = await response.json();
return result.data.paymentLinks
.filter(link => !customerId || link.metadata?.customerId === customerId)
.map(link => ({
id: link.id,
concept: link.concept,
amount: link.amount,
status: link.status,
createdAt: link.createdAt,
metadata: link.metadata
}));
}Buenas prácticas
Manejo de errores
Centraliza el manejo de errores en una función reutilizable:
class ZeltaPayError extends Error {
constructor(status, code, message, issues = []) {
super(message);
this.status = status;
this.code = code;
this.issues = issues;
}
}
async function zeltaPayRequest(endpoint, options = {}) {
const response = await fetch(`https://api-pay.zelta.dev${endpoint}`, {
...options,
headers: {
'X-API-Key': process.env.ZELTA_API_KEY,
'Content-Type': 'application/json',
...options.headers
}
});
if (response.status === 429) {
// Rate limit: cuerpo en texto plano, espera Retry-After segundos
throw new ZeltaPayError(429, 'RATE_LIMITED', `Reintenta en ${response.headers.get('Retry-After')}s`);
}
const result = await response.json();
if (!result.success) {
// Errores de negocio: error.code + message. Errores de validación: error.issues
throw new ZeltaPayError(
response.status,
result.error?.code ?? 'VALIDATION_ERROR',
result.message ?? 'Datos inválidos',
result.error?.issues ?? []
);
}
return result.data;
}Rate limiting
Respeta el límite de 60 solicitudes por ventana de 60 segundos de cada API key implementando un control de tasa en tu aplicación:
class RateLimiter {
constructor(maxRequests = 60, windowMs = 60000) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
this.requests = [];
}
async waitForSlot() {
const now = Date.now();
this.requests = this.requests.filter(t => now - t < this.windowMs);
if (this.requests.length >= this.maxRequests) {
const oldestRequest = this.requests[0];
const waitTime = this.windowMs - (now - oldestRequest) + 100;
await new Promise(resolve => setTimeout(resolve, waitTime));
}
this.requests.push(Date.now());
}
}
const limiter = new RateLimiter();
async function createPaymentLinkWithRateLimit(data) {
await limiter.waitForSlot();
return await zeltaPayRequest('/v1/payment-links', {
method: 'POST',
body: JSON.stringify(data)
});
}Caché
Si consultas frecuentemente el estado de los links, implementa un caché para reducir llamadas a la API:
const cache = new Map();
const CACHE_TTL = 30000; // 30 segundos
async function getPaymentLinkCached(hashUrl) {
const cacheKey = `link:${hashUrl}`;
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data;
}
const data = await zeltaPayRequest(`/v1/payment-links/${hashUrl}`);
cache.set(cacheKey, { data, timestamp: Date.now() });
return data;
}Caché y estados de pago
No confíes solo en el caché para determinar si un pago fue completado. Usa para recibir notificaciones en tiempo real cuando cambie el estado de un pago.
Logging
Registra las interacciones con la API para facilitar la depuración:
async function zeltaPayRequestWithLogging(endpoint, options = {}) {
const requestId = crypto.randomUUID();
const startTime = Date.now();
console.log(JSON.stringify({
type: 'zelta_pay_request',
requestId,
endpoint,
method: options.method || 'GET',
timestamp: new Date().toISOString()
}));
try {
const result = await zeltaPayRequest(endpoint, options);
console.log(JSON.stringify({
type: 'zelta_pay_response',
requestId,
success: true,
durationMs: Date.now() - startTime
}));
return result;
} catch (error) {
console.error(JSON.stringify({
type: 'zelta_pay_error',
requestId,
code: error.code,
message: error.message,
durationMs: Date.now() - startTime
}));
throw error;
}
}Testing
Tests unitarios
Verifica la lógica de tu integración con tests unitarios:
import { describe, it, expect, vi } from 'vitest';
describe('createProductPaymentLink', () => {
it('debe crear un link con los datos correctos del producto', async () => {
const mockFetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({
success: true,
data: {
paymentLink: {
id: '7312345678901234567',
paymentLinkUrl: 'https://pay.zelta.dev/Xk3v9Q_aB7mN2pL8rT4wYz1c',
status: 'pending'
}
}
})
});
global.fetch = mockFetch;
const product = { id: 'prod_001', name: 'Test Product', priceInCents: 5000, sku: 'TST-001' };
const customer = { id: 'cust_001', name: 'Test User', email: '[email protected]' };
const result = await createProductPaymentLink(product, customer);
expect(result.success).toBe(true);
expect(result.data.paymentLink.status).toBe('pending');
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(requestBody.amount).toBe(5000);
expect(requestBody.metadata.productId).toBe('prod_001');
});
it('debe manejar un monto por debajo del mínimo', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({
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' }
})
});
const product = { id: 'prod_001', name: 'Test', priceInCents: 50, sku: 'TST' };
const customer = { id: 'cust_001', name: 'Test', email: '[email protected]' };
const result = await createProductPaymentLink(product, customer);
expect(result.success).toBe(false);
expect(result.error.code).toBe('ERR_AMOUNT_BELOW_NMI_MINIMUM');
});
});Tests de integración
Usa el modo de prueba (isTest: true) para ejecutar tests de integración contra la API real:
import { describe, it, expect } from 'vitest';
describe('Zelta Pay Integration', () => {
it('debe crear y consultar un link de pago de prueba', async () => {
// Crear link de prueba
const createResponse = 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: 'Test de integración',
amount: 1000,
customerName: 'Test User',
isTest: true,
metadata: { testId: 'integration-001' }
})
});
const createResult = await createResponse.json();
expect(createResult.success).toBe(true);
const { id: paymentLinkId, paymentLinkUrl } = createResult.data.paymentLink;
const hashUrl = new URL(paymentLinkUrl).pathname.split('/').pop();
// Consultar el link creado (por su hashUrl, no por su id)
const getResponse = await fetch(
`https://api-pay.zelta.dev/v1/payment-links/${hashUrl}`,
{
headers: {
'X-API-Key': process.env.ZELTA_API_KEY,
'Content-Type': 'application/json'
}
}
);
const getResult = await getResponse.json();
expect(getResult.success).toBe(true);
expect(getResult.data.paymentLink.id).toBe(paymentLinkId);
expect(getResult.data.paymentLink.isTest).toBe(true);
});
});Usa modo de prueba para tests
Siempre usa isTest: true en tus tests de integración. Los links de prueba se cobran con las credenciales de sandbox de tus métodos de pago, así que no mueven dinero real. Necesitas un proveedor activo en sandbox para crearlos.
Siguientes pasos
- -- Referencia completa para crear links de pago
- -- Recibe notificaciones en tiempo real
- -- API keys y rate limiting
- -- Documentación completa de endpoints