Skip to content

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.

All API requests must include your API key in the X-API-Key header:

X-API-Key: your-api-key-here

The server validates this key before processing your request. Each account can have multiple API keys for different applications or environments.

  1. Log in to your Zelta Pay Dashboard
  2. Open API Key in the sidebar (the item is shown to the admin and owner roles only)
  3. In the Nueva API key card, type a descriptive alias (e.g., “Production App”, “Mobile App”) and click Crear
  4. 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.

  • 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 /v1 endpoint (payment links, products, customers, subscriptions, collections and charges)

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.

HeaderDescriptionExample
RateLimit-PolicyLimit and window length in seconds60;w=60
RateLimit-LimitMaximum requests allowed in the window60
RateLimit-RemainingRequests left in the window45
RateLimit-ResetSeconds until the window resets (omitted when it reaches 0)37
Retry-AfterSeconds to wait before retrying (only on a 429)37
HTTP/1.1 200 OK
RateLimit-Policy: 60;w=60
RateLimit-Limit: 60
RateLimit-Remaining: 59
RateLimit-Reset: 60
Content-Type: application/json

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 Requests
Content-Type: text/plain; charset=UTF-8
Retry-After: 37
RateLimit-Policy: 60;w=60
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-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.

  1. Monitor rate limit headers in your responses
  2. Wait for Retry-After before retrying a 429
  3. Use multiple API keys for high-volume applications
  4. Cache responses when possible to reduce API calls
CodeHTTPDescriptionSolution
ERR_API_KEY_MISSING401The X-API-Key header is missingAdd the X-API-Key header to your request
ERR_ACCOUNT_API_KEY_NOT_FOUND404Unknown API key: mistyped, regenerated or deletedCopy the current key from the dashboard
ERR_ACCOUNT_NOT_FOUND404The key’s account no longer existsContact support@zelta.dev
ERR_ACCOUNT_SUSPENDED403Account is suspendedContact support@zelta.dev
ERR_PROJECT_NOT_FOUND404Project-scoped key whose project no longer existsUse a key of an existing project
ERR_PROJECT_INACTIVE403Project-scoped key whose project is not activeUse 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).

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

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;
}
// Usage
try {
const paymentLinks = await makeApiRequest('/v1/payment-links');
console.log(paymentLinks);
} catch (error) {
console.error('API Error:', error.message);
}
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' }
});
}
}
};
import { Hono } from 'hono';
import { cors } from 'hono/cors';
type Bindings = {
ZELTA_API_KEY: string;
};
const app = new Hono<{ Bindings: Bindings }>();
// Enable CORS
app.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 endpoint
app.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;
import requests
import 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
# Usage
try:
payment_links = make_api_request('/v1/payment-links')
print(payment_links)
except Exception as error:
print(f'API Error: {error}')
<?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);
}
// Usage
try {
$paymentLinks = makeApiRequest('/v1/payment-links');
print_r($paymentLinks);
} catch (Exception $e) {
echo 'API Error: ' . $e->getMessage();
}
?>

Store API keys in environment variables:

Terminal window
# .env file
ZELTA_API_KEY=your-api-key-here

Regularly rotate your API keys:

  1. Create a new API key
  2. Update your applications
  3. Delete the old key
  • Use different API keys for different applications
  • Monitor each key’s last use (“Último uso”) in your dashboard

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);
}
}
Terminal window
curl -X GET "https://api-pay.zelta.dev/v1/payment-links" \
-H "X-API-Key: your-api-key-here" \
-H "Content-Type: application/json"
{
"success": true,
"data": {
"paymentLinks": [],
"total": 0
},
"message": "Operation completed successfully",
"timestamp": "2024-01-15T12:00:00.000Z"
}

401 Unauthorized

  • The X-API-Key header is missing: ensure it is included (and spelled X-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-After seconds before retrying
  • Implement rate limiting in your application
  • Use multiple API keys for high volume
  • Cache responses when possible

Now that you understand authentication, explore these guides: