Authentication
Zelta Pay API uses API key-based authentication for all public endpoints. This guide covers how to obtain, use, and manage your API keys securely.
API Key Authentication
Section titled “API Key Authentication”How It Works
Section titled “How It Works”All API requests must include your API key in the X-API-Key header:
X-API-Key: your-api-key-hereThe server validates this key before processing your request. Each account can have multiple API keys for different applications or environments.
Getting Your API Key
Section titled “Getting Your API Key”- Log in to your Zelta Pay Dashboard
- Open API Key in the sidebar (the item is shown to the admin and owner roles only)
- In the Nueva API key card, type a descriptive alias (e.g., “Production App”, “Mobile App”) and click Crear
- Click Ver llave on the new row, then Copiar, and store the key securely
There is no separate save step: the key is active as soon as it is created. You can show it again later with Ver llave, or replace it with Regenerar (the old value stops working immediately). The dashboard keeps Crear disabled until the account has an active payment provider.
Security
Never commit API keys to version control. Use environment variables or secure secret management systems instead.
Rate Limiting
Section titled “Rate Limiting”Limits and Windows
Section titled “Limits and Windows”- Limit: 60 requests per window per API key
- Window: fixed 60-second window. It starts at the key’s first request after the previous window ended, and the counter resets to zero when it closes
- Per-key: Each API key has its own independent limit, shared by every
/v1endpoint (payment links, products, customers, subscriptions, collections and charges)
Rate Limit Headers
Section titled “Rate Limit Headers”Responses to /v1 requests carry rate limit headers once the API key has been validated. An authentication failure (401, 403 or 404) has no rate limit headers.
| Header | Description | Example |
|---|---|---|
RateLimit-Policy | Limit and window length in seconds | 60;w=60 |
RateLimit-Limit | Maximum requests allowed in the window | 60 |
RateLimit-Remaining | Requests left in the window | 45 |
RateLimit-Reset | Seconds until the window resets (omitted when it reaches 0) | 37 |
Retry-After | Seconds to wait before retrying (only on a 429) | 37 |
Example Headers
Section titled “Example Headers”HTTP/1.1 200 OKRateLimit-Policy: 60;w=60RateLimit-Limit: 60RateLimit-Remaining: 59RateLimit-Reset: 60Content-Type: application/jsonHandling Rate Limits
Section titled “Handling Rate Limits”When you exceed the rate limit, you’ll receive a 429 Too Many Requests response. Its body is plain text, not JSON, and has no error code:
HTTP/1.1 429 Too Many RequestsContent-Type: text/plain; charset=UTF-8Retry-After: 37RateLimit-Policy: 60;w=60RateLimit-Limit: 60RateLimit-Remaining: 0RateLimit-Reset: 37
Too many requests, please try again later.Calling response.json() on this body throws. Check response.status === 429 first and wait the number of seconds in Retry-After (or RateLimit-Reset) before retrying.
Best Practices
Section titled “Best Practices”- Monitor rate limit headers in your responses
- Wait for
Retry-Afterbefore retrying a429 - Use multiple API keys for high-volume applications
- Cache responses when possible to reduce API calls
Error Handling
Section titled “Error Handling”Common Authentication Errors
Section titled “Common Authentication Errors”| Code | HTTP | Description | Solution |
|---|---|---|---|
ERR_API_KEY_MISSING | 401 | The X-API-Key header is missing | Add the X-API-Key header to your request |
ERR_ACCOUNT_API_KEY_NOT_FOUND | 404 | Unknown API key: mistyped, regenerated or deleted | Copy the current key from the dashboard |
ERR_ACCOUNT_NOT_FOUND | 404 | The key’s account no longer exists | Contact support@zelta.dev |
ERR_ACCOUNT_SUSPENDED | 403 | Account is suspended | Contact support@zelta.dev |
ERR_PROJECT_NOT_FOUND | 404 | Project-scoped key whose project no longer exists | Use a key of an existing project |
ERR_PROJECT_INACTIVE | 403 | Project-scoped key whose project is not active | Use a key of an active project, or contact support@zelta.dev |
An invalid API key returns 404, not 401: 401 only means the header is missing. A rate-limited request returns 429 with a plain-text body and no error code (see Handling Rate Limits).
Error Response Format
Section titled “Error Response Format”{ "success": false, "message": "Api key not found", "error": { "code": "ERR_ACCOUNT_API_KEY_NOT_FOUND" }, "timestamp": "2024-01-15T12:00:00.000Z"}Match on error.code, not on the message text.
Implementation Examples
Section titled “Implementation Examples”Node.js Example
Section titled “Node.js Example”const apiKey = process.env.ZELTA_API_KEY;
async function makeApiRequest(endpoint, options = {}) { const response = await fetch(`https://api-pay.zelta.dev${endpoint}`, { ...options, headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json', ...options.headers } });
if (response.status === 429) { // Plain-text body: don't call response.json() const retryAfter = Number(response.headers.get('Retry-After') ?? 60); const error = new Error(`Rate limited, retry in ${retryAfter}s`); error.status = 429; error.retryAfter = retryAfter; throw error; }
const result = await response.json();
if (!response.ok) { // 400 validation errors have error.issues and no message const error = new Error( result.message ?? (result.error?.issues ?? []) // unrecognized_keys issues have path [] and name the fields in keys .map((i) => `${i.path.join('.') || (i.keys ?? []).join(',')}: ${i.message}`) .join('; ') ); error.status = response.status; error.code = result.error?.code; throw error; }
return result;}
// Usagetry { const paymentLinks = await makeApiRequest('/v1/payment-links'); console.log(paymentLinks);} catch (error) { console.error('API Error:', error.message);}Cloudflare Workers Example
Section titled “Cloudflare Workers Example”export default { async fetch(request, env, ctx) { const apiKey = env.ZELTA_API_KEY;
async function makeApiRequest(endpoint, options = {}) { const response = await fetch(`https://api-pay.zelta.dev${endpoint}`, { ...options, headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json', ...options.headers } });
if (response.status === 429) { // Plain-text body: don't call response.json() throw new Error(`Rate limited, retry in ${response.headers.get('Retry-After')}s`); }
const result = await response.json();
if (!response.ok) { // 400 validation errors have error.issues and no message throw new Error(result.message ?? JSON.stringify(result.error?.issues)); }
return result; }
// Example usage try { const paymentLinks = await makeApiRequest('/v1/payment-links'); return new Response(JSON.stringify(paymentLinks), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } }};Hono with Cloudflare Workers Example
Section titled “Hono with Cloudflare Workers Example”import { Hono } from 'hono';import { cors } from 'hono/cors';
type Bindings = { ZELTA_API_KEY: string;};
const app = new Hono<{ Bindings: Bindings }>();
// Enable CORSapp.use('*', cors());
async function makeApiRequest(endpoint: string, apiKey: string, options: RequestInit = {}) { const response = await fetch(`https://api-pay.zelta.dev${endpoint}`, { ...options, headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json', ...options.headers } });
if (response.status === 429) { // Plain-text body: don't call response.json() throw new Error(`Rate limited, retry in ${response.headers.get('Retry-After')}s`); }
const result: any = await response.json();
if (!response.ok) { // 400 validation errors have error.issues and no message throw new Error(result.message ?? JSON.stringify(result.error?.issues)); }
return result;}
// Example endpointapp.get('/payment-links', async (c) => { try { const paymentLinks = await makeApiRequest('/v1/payment-links', c.env.ZELTA_API_KEY); return c.json(paymentLinks); } catch (error) { return c.json({ error: error.message }, 500); }});
export default app;Python Example
Section titled “Python Example”import requestsimport os
api_key = os.getenv('ZELTA_API_KEY')
def make_api_request(endpoint, **kwargs): headers = { 'X-API-Key': api_key, 'Content-Type': 'application/json' } headers.update(kwargs.get('headers', {}))
response = requests.request( method=kwargs.get('method', 'GET'), url=f'https://api-pay.zelta.dev{endpoint}', headers=headers, **{k: v for k, v in kwargs.items() if k not in ['method', 'headers']} )
if response.status_code == 429: # Plain-text body: don't call response.json() raise Exception(f"Rate limited, retry in {response.headers.get('Retry-After')}s")
result = response.json()
if not response.ok: # 400 validation errors have error.issues and no message raise Exception(result.get('message') or result.get('error', {}).get('issues'))
return result
# Usagetry: payment_links = make_api_request('/v1/payment-links') print(payment_links)except Exception as error: print(f'API Error: {error}')PHP Example
Section titled “PHP Example”<?php$apiKey = $_ENV['ZELTA_API_KEY'];
function makeApiRequest($endpoint, $options = []) { global $apiKey;
$headers = [ 'X-API-Key: ' . $apiKey, 'Content-Type: application/json' ];
if (isset($options['headers'])) { $headers = array_merge($headers, $options['headers']); }
$ch = curl_init(); curl_setopt($ch, CURLOPT_URL, 'https://api-pay.zelta.dev' . $endpoint); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
if (isset($options['method'])) { curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $options['method']); }
if (isset($options['data'])) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($options['data'])); }
$response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
if ($httpCode == 429) { // Plain-text body, not JSON throw new Exception('Rate limited: ' . $response); }
if ($httpCode >= 400) { $error = json_decode($response, true); // 400 validation errors have error.issues and no message throw new Exception($error['message'] ?? json_encode($error['error']['issues'] ?? [])); }
return json_decode($response, true);}
// Usagetry { $paymentLinks = makeApiRequest('/v1/payment-links'); print_r($paymentLinks);} catch (Exception $e) { echo 'API Error: ' . $e->getMessage();}?>Security Best Practices
Section titled “Security Best Practices”1. Environment Variables
Section titled “1. Environment Variables”Store API keys in environment variables:
# .env fileZELTA_API_KEY=your-api-key-here2. Key Rotation
Section titled “2. Key Rotation”Regularly rotate your API keys:
- Create a new API key
- Update your applications
- Delete the old key
3. Access Control
Section titled “3. Access Control”- Use different API keys for different applications
- Monitor each key’s last use (“Último uso”) in your dashboard
4. Error Handling
Section titled “4. Error Handling”Always handle authentication errors gracefully:
// `error` as thrown by makeApiRequest above (status, code, retryAfter)async function handleApiError(error) { if (error.code === 'ERR_ACCOUNT_API_KEY_NOT_FOUND') { console.error('Invalid API key'); // Redirect to settings or show error } else if (error.status === 429) { console.error('Rate limit exceeded'); // Wait error.retryAfter seconds before retrying } else { console.error('API Error:', error.message); }}Testing Authentication
Section titled “Testing Authentication”Test Your API Key
Section titled “Test Your API Key”curl -X GET "https://api-pay.zelta.dev/v1/payment-links" \ -H "X-API-Key: your-api-key-here" \ -H "Content-Type: application/json"Expected Response
Section titled “Expected Response”{ "success": true, "data": { "paymentLinks": [], "total": 0 }, "message": "Operation completed successfully", "timestamp": "2024-01-15T12:00:00.000Z"}Troubleshooting
Section titled “Troubleshooting”Common Issues
Section titled “Common Issues”401 Unauthorized
- The
X-API-Keyheader is missing: ensure it is included (and spelledX-API-Key)
404 Not Found with ERR_ACCOUNT_API_KEY_NOT_FOUND
- The key is wrong, was regenerated or was deleted. API keys have no active/inactive state: a key either exists or doesn’t
- Copy the current key from API Key → Ver llave in the dashboard
403 Forbidden
- Account may be suspended (
ERR_ACCOUNT_SUSPENDED), or the key’s project is inactive (ERR_PROJECT_INACTIVE) - Contact support for assistance
429 Too Many Requests
- Wait
Retry-Afterseconds before retrying - Implement rate limiting in your application
- Use multiple API keys for high volume
- Cache responses when possible
Next Steps
Section titled “Next Steps”Now that you understand authentication, explore these guides:
- Quick Start - Create your first payment link
- Creating Payment Links - Advanced payment link options
- Webhooks - Real-time payment notifications
- API Reference - Complete endpoint documentation