Skip to content

API Overview

The Zelta Pay API provides a RESTful interface for creating and managing payment links. This overview covers the essential information you need to get started.

https://api-pay.zelta.dev

All endpoints are versioned using the URL path:

/v1/payment-links

Breaking changes will be introduced under new version paths (e.g., /v2/payment-links).

The Zelta Pay API uses API key authentication. Include your API key in the X-API-Key header:

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

Where to get your API key:

  1. Log in to your Zelta Pay Dashboard
  2. Open API Key in the sidebar (shown to the admin and owner roles only)
  3. Type an alias in the Nueva API key card and click Crear (the button stays disabled until the account has an active payment provider)
  4. Click Ver llave on the new row, then Copiar
  5. Store it securely (never commit to version control)

Protected endpoints require JWT access token in the Authorization header:

Authorization: Bearer <your_access_token>

Additional header required for account-scoped operations:

X-Account-ID: <your_account_id>
  • Limit: 60 requests per window per API key
  • Window: fixed 60-second window that starts at the key’s first request after the previous window ended
  • Scope: one budget per API key, shared by every /v1 endpoint (payment links, products, customers, subscriptions, collections and charges)

Headers (sent on /v1 responses once the API key has been validated; a 401, 403 or 404 authentication failure carries none of them):

  • RateLimit-Policy: The policy, 60;w=60
  • RateLimit-Limit: Maximum requests allowed in the window (60)
  • RateLimit-Remaining: Requests remaining in the current window
  • RateLimit-Reset: Seconds until the window resets (for example 37), not a timestamp. Omitted when it reaches 0
  • Retry-After: Seconds to wait before retrying (only on a 429)

A 429 response has a plain-text body (Content-Type: text/plain), not the JSON envelope, and 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.

Check response.status === 429 before calling response.json(), which throws on this body.

All API responses follow a consistent JSON structure:

{
"success": true,
"data": {
"paymentLink": {
"id": "7312345678901234567",
"paymentLinkUrl": "https://pay.zelta.dev/Xk3v9Q_aB7mN2pL8rT4wYz1c",
"customerName": "John Doe",
"concept": "Consulting Service",
"amount": 2500,
"status": "pending",
"createdAt": "2024-01-15T10:30:00.000Z",
"expiresAt": "2024-01-18T10:30:00.000Z",
"isTest": true
}
},
"message": "Payment link created successfully",
"timestamp": "2024-01-15T10:30:00.000Z"
}

Response Fields:

  • success: Always true for successful responses
  • data: The response payload, always an object (single items come wrapped, e.g. { paymentLink }; lists return { <items>, total }, e.g. { paymentLinks, total })
  • message: Always present. Endpoints without a specific message return "Operation completed successfully"
  • timestamp: ISO 8601 timestamp of the response

IDs such as id, accountId or projectId are numeric strings (for example "7312345678901234567") with no prefix. The last path segment of paymentLinkUrl is the link’s 24-character hashUrl (letters, digits, - and _), which is what GET /v1/payment-links/{hashUrl} and the cancel endpoint take.

{
"success": false,
"message": "Error description",
"error": {
"code": "ERROR_CODE",
"details": "Additional error details"
},
"timestamp": "2024-01-15T10:30:00.000Z"
}

Error Fields:

  • success: Always false for errors
  • message: User-friendly error description
  • error.code: Machine-readable error code
  • error.details: (Optional) Technical details
  • timestamp: ISO 8601 timestamp

There are two exceptions to this shape:

  • Request validation errors (400, an invalid body or query parameter) return the validation result, with error.issues listing the failing fields, and no message, error.code or timestamp. See Validation Errors.
  • Rate limit errors (429) have a plain-text body. See Rate Limiting.

A body that is not valid JSON does use the standard envelope:

{
"success": false,
"message": "Malformed JSON in request body",
"error": {
"code": "HTTPException"
},
"timestamp": "2024-01-15T10:30:00.000Z"
}

Send the body with a JSON Content-Type (e.g. application/json). Without it the body is ignored, and every required field (concept, amount, customerName, isTest) is reported as Required in error.issues.

CodeDescription
200OK - Request successful
201Created - Resource created successfully
202Accepted - Request accepted; final result delivered asynchronously (e.g. a charge still settling — see the Integration API)
400Bad Request - Invalid request data or validation error
401Unauthorized - Missing X-API-Key header, or invalid JWT
403Forbidden - Account suspended, project inactive, or insufficient permissions
404Not Found - Resource not found, or unknown API key (an invalid API key returns 404, not 401)
409Conflict - The resource is busy (e.g. cancelling a link whose payment is being confirmed)
429Too Many Requests - Rate limit exceeded (plain-text body, see Rate Limiting)
500Internal Server Error - Server error
502Bad Gateway - Service temporarily unavailable
503Service Unavailable - Service maintenance or overload
504Gateway Timeout - Request timeout
CodeHTTPDescription
ERR_API_KEY_MISSING401The X-API-Key header is missing (message "Missing X-API-Key header")
ERR_ACCOUNT_API_KEY_NOT_FOUND404Unknown, regenerated or deleted API key (message "Api key not found")
ERR_ACCOUNT_NOT_FOUND404The key’s account no longer exists
ERR_ACCOUNT_SUSPENDED403Account is suspended
ERR_PROJECT_NOT_FOUND404Project-scoped key whose project no longer exists
ERR_PROJECT_INACTIVE403Project-scoped key whose project is not active
ERR_API_KEY_NOT_PROJECT_SCOPED403An account-level key was used on /v1/products, /v1/customers, /v1/subscriptions, /v1/collections or /v1/charges
ERR_UNAUTHORIZED401Invalid JWT token (dashboard endpoints)

An invalid body or query parameter (a missing concept, an amount of 0, negative, non-integer or above 1000000, invalid metadata, an expiresInDays outside 1-60, a lim above 100, an unknown body field…) returns 400 without an error code. error.issues lists the failing fields: path names the field, message explains the problem and code is the kind of check that failed (invalid_type, too_small, too_big, unrecognized_keys, custom…). Unknown body fields are rejected with one unrecognized_keys issue whose path is [] and whose keys lists the unknown field names; unknown query parameters are ignored. The cross-field check (customerEmail together with requestCustomerEmail: true) is usually reported together with the other issues, but some field errors stop it from running (for example a missing field or an invalid paymentMethods value), so it can appear on a later request after you fix them.

An amount from 1 to 99 cents passes this validation and is rejected afterwards with the coded ERR_AMOUNT_BELOW_NMI_MINIMUM error (see Business Logic Errors).

{
"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"
}
}
CodeHTTPDescription
ERR_NO_ACTIVE_PAYMENT_PROVIDER400No enabled payment provider for the link’s environment: an isTest: true link needs an enabled sandbox provider, an isTest: false link an enabled production provider
ERR_INACTIVE_PAYMENT_METHOD400Specified payment method is not active on the account for the link’s environment
ERR_AMOUNT_BELOW_NMI_MINIMUM400amount is between 1 and 99 cents. Message: "El monto mínimo para un link de pago es 1.00 USD (100 centavos); no se puede generar el link de pago". Checked after the provider and payment-method checks. A project-scoped key also gets a payment_link.rejected webhook
ERR_INVALID_FIELDS400The fields query parameter names an unknown column (see Field Selection)
ERR_INVALID_RELATIONS400expand names a relation the endpoint does not expand (payment links have none; charges accept customer and product)
HTTPException400The request body is not valid JSON (message "Malformed JSON in request body")
ERR_PAYMENT_LINK_NOT_FOUND404Payment link does not exist, or the API key cannot reach it (another account’s link or, with a project key, a link outside the project)
ERR_INVALID_STATE400Payment link is in a state that doesn’t allow the operation
ERR_PAYMENT_IN_PROGRESS409A payment for the link is being confirmed (link status processing), so it cannot be cancelled yet
ERR_INTERNAL_SERVER_ERROR500Unexpected server error (message "Internal Server Error")

A 429 rate-limit response has no error code: its body is the plain text Too many requests, please try again later. (see Rate Limiting).

All public endpoints require the X-API-Key header and are rate-limited to 60 requests per 60-second window per API key.

MethodEndpointDescription
GET/v1/payment-linksList payment links
GET/v1/payment-links/{hashUrl}Retrieve a single payment link
POST/v1/payment-linksCreate a payment link
PATCH/v1/payment-links/{hashUrl}/cancelCancel a payment link
ParameterTypeDescription
limintegerPage size, 1-100 (default 10)
offintegerOffset, non-negative (default 0)
Terminal window
curl -X GET "https://api-pay.zelta.dev/v1/payment-links?lim=10&off=20" \
-H "X-API-Key: your-api-key-here"

GET /v1/payment-links accepts a fields parameter that limits which columns are read. It is not supported on other endpoints.

Terminal window
curl -X GET "https://api-pay.zelta.dev/v1/payment-links?fields=id,hashUrl,amount,status,expiresAt" \
-H "X-API-Key: your-api-key-here"
  • Values are the payment link’s column names in camelCase (id, hashUrl, amount, status, concept, customerName, customerEmail, expiresAt, allowedPaymentMethods…), not the response field names. A response name such as paymentLinkUrl or paymentMethods returns 400 ERR_INVALID_FIELDS.
  • Fields you don’t select are left out of each item.
  • paymentLinkUrl is always present, and ends in /undefined unless you select hashUrl.
  • Unless you select both expiresAt and status, the list does not mark overdue links as expired (see Status Values).
  • expand is not supported: any value returns 400 ERR_INVALID_RELATIONS.

All monetary amounts are specified in cents (the smallest currency unit):

  • 100 = $1.00
  • 2500 = $25.00
  • 50000 = $500.00

All timestamps are in ISO 8601 format with UTC timezone:

2024-01-15T10:30:00.000Z

Payment links can have these statuses:

  • pending - Waiting for payment
  • processing - A payment is being confirmed (with PayPal this can last up to several days while PayPal reviews the payment)
  • completed - Payment successful
  • cancelled - Manually cancelled
  • expired - Past its expiresAt and stored as expired

Links are not expired proactively and no webhook is sent on expiry. A pending link past its expiresAt is stored as expired the first time a list call (GET /v1/payment-links or the dashboard) returns it on a page; that same response still shows it as pending, and later reads show expired. Until then GET /v1/payment-links/{hashUrl} keeps returning pending, a status=pending filter can return overdue links and status=expired can miss them. To know whether a link can still be paid, compare expiresAt with the current time: payment attempts after expiresAt are rejected (“Payment link has expired”).

Payment links can offer these payment methods (the paymentMethods field). Each must be active on your account, configured in the dashboard sidebar under Métodos de pago (admin and owner roles only), for the link’s environment (sandbox or production):

MethodProviderCustomer experienceAvailable on
cardYour card processor (NMI or Emetec)Credit/debit card formAll links
yappyYappyPay with YappyOne-time payment links
paypalPayPalPay with a PayPal account, or with a debit/credit card through PayPal’s guest checkout (no PayPal account needed)One-time payment links

Webhooks report the provider in transaction.paymentProvider (nmi, emetec, yappy, paypal) and the method in transaction.paymentMethod (credit_card, yappy, paypal). See Webhook Events.

Payment links support custom metadata for storing application-specific information.

  • Keys: 1-40 characters (any characters)
  • Values: String (max 255), number, boolean, or null
  • Maximum keys: 20 per payment link
  • Maximum total size: 8,192 bytes when JSON serialized
{
"metadata": {
"orderId": "ORD-001",
"customerId": 12345,
"isVip": true,
"notes": null
}
}

List endpoints support pagination using query parameters:

ParameterTypeDescription
limintegerItems per page, 1-100 (default 10; a value above 100 returns 400)
offintegerOffset from start, non-negative (default 0)

Results come newest first (createdAt descending). The response carries the page and the number of matching items, { paymentLinks, total }. To fetch every link, keep requesting pages with off increased by the page size until you have total items:

let off = 0;
const all = [];
while (true) {
const res = await fetch(`https://api-pay.zelta.dev/v1/payment-links?lim=100&off=${off}`, {
headers: { 'X-API-Key': process.env.ZELTA_PAY_API_KEY }
});
const { data } = await res.json();
all.push(...data.paymentLinks);
off += data.paymentLinks.length;
if (data.paymentLinks.length === 0 || off >= data.total) break;
}
Terminal window
curl -X GET "https://api-pay.zelta.dev/v1/payment-links?lim=10&off=20" \
-H "X-API-Key: your-api-key-here"

Payment link events:

  • payment.success - A customer completed a payment
  • payment.failed - A payment attempt ended without payment (decline, error, cancelled or expired wallet payment)
  • receipt.created - The PDF receipt of a payment was generated
  • webhook.ping - Sent when you test your webhook endpoint from the dashboard
  • payment_link.rejected - Only for links requested with a project-scoped key: the link was refused because amount is under 100 cents

Integration projects also receive customer.created, charge.succeeded, charge.failed, charge.deferred, charge.unknown, subscription.*, invoice.paid and invoice.payment_failed. See Webhook Events for every payload.

There is no per-event subscription: the webhook receives every event of its account (or project). Ignore the types you don’t handle.

  • Public HTTPS only - HTTP URLs, internal hostnames and private IP addresses are rejected

  • Signature verification - All webhooks include HMAC-SHA256 signatures

  • Timestamp validation - Prevent replay attacks

  • Idempotency support - Use eventId (a UUID, also sent as the Zeltapay-Event-Id header) to prevent duplicate processing

  • Delivery: HTTPS POST; failed deliveries are retried up to 5 times, 15 seconds apart (see Delivery & Retries)

  • Security: HMAC-SHA256 signature verification

  • Idempotency: Event ID prevents duplicate processing

While Zelta Pay provides a REST API, you can use any HTTP client:

const fetch = require('node-fetch');
// or
const axios = require('axios');
import requests
# or
import httpx
// Built-in cURL
// or Guzzle HTTP
import "net/http"
// or
import "github.com/go-resty/resty/v2"

Use isTest: true when creating payment links for testing:

{
"concept": "Test Payment",
"amount": 100,
"customerName": "Test User",
"isTest": true
}

For detailed version history and API changes, see the complete changelog.

  • Optional expiresInDays (integer, 1-60; default 3 days) on POST /v1/payment-links
  • PayPal payment method ("paypal"): PayPal wallet and guest card checkout, one-time payment links only
  • paypal value for the paymentProvider list parameter, and processing value for the status filter
  • Cancel returns 409 ERR_PAYMENT_IN_PROGRESS while a payment is being confirmed
  • Payment method selection (paymentMethods parameter)
  • Request customer email flow (requestCustomerEmail parameter)
  • paymentProvider query parameter on the list endpoint. It is accepted (an unknown value returns 400) but currently ignored for API-key requests: the list and total are not filtered by provider
  • Initial API release
  • Payment link CRUD operations
  • Webhook support
  • Rate limiting
  • Metadata support