Skip to content

Creating Payment Links

This guide covers everything you need to know about creating payment links with the Zelta Pay API, including required fields, validation rules, and best practices.

To create a payment link, send a POST request to the /v1/payment-links endpoint with the required parameters in the request body.

POST /v1/payment-links

ParameterTypeRequiredDescription
conceptstringYesDescription of what’s being paid for
amountintegerYesAmount in cents (minimum 100, maximum 1000000)
customerNamestringYesName of the customer (1-120 characters)
isTestbooleanYesWhether this is a test payment
customerEmailstringNoCustomer’s email address (valid email format)
requestCustomerEmailbooleanNoWhether the payment page asks the customer for their email. Defaults to true when omitted. Sending true together with customerEmail is rejected; send false to skip the email step.
redirectUrlstringNoCustom http/https redirect URL after payment
metadataobjectNoCustom metadata (max 20 keys, 8192 bytes)
paymentMethodsstring[]NoAllowed payment methods ("card", "yappy", "paypal"). At least one required when specified. No duplicates allowed.
expiresInDaysintegerNoHow many days the link stays payable, from 1 to 60. Defaults to 3 days when omitted.

String values are trimmed of leading and trailing whitespace before they are validated.

  • Minimum: 100 cents ($1.00)
  • Maximum: 1,000,000 cents ($10,000.00)
  • Type: Positive integer

0, negative, non-integer and above-1,000,000 amounts return the 400 validation error (error.issues). An amount from 1 to 99 cents passes that validation and is then rejected with a coded 400 in the standard envelope; this check runs after the active-provider and payment-method checks:

{
"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"
},
"timestamp": "2024-01-15T10:30:00.000Z"
}

With a project-scoped API key, a payment_link.rejected webhook is also sent to the project’s webhook (if one is enabled).

  • Required: Cannot be empty
  • Type: String
  • Required: Cannot be empty
  • Length: 1-120 characters (after trimming)
  • Type: String
  • Type: Valid email format
  • Optional: Will prefill the email field on the hosted payment page
  • Type: Valid URL
  • Maximum length: 2048 characters
  • Protocol: http:// or https:// only; any other scheme returns 400 (“redirectUrl must use the http or https scheme”). HTTPS recommended
  • Type: Boolean
  • Optional: Defaults to true when omitted
  • Constraint: An explicit requestCustomerEmail: true sent together with customerEmail is rejected
  • With customerEmail: If you send customerEmail and omit requestCustomerEmail, the link is stored and returned with requestCustomerEmail: true, and the payment page still asks for the email (prefilled). Send requestCustomerEmail: false to skip the email step
  • Keys: 1-40 characters (any characters)
  • Values: String (max 255 chars), number, boolean, or null
  • Maximum keys: 20 per payment link
  • Total size: Maximum 8192 bytes when JSON serialized
  • Cannot be empty object: If metadata is provided, it must contain at least one key-value pair
  • Type: Array of strings
  • Allowed values: "card", "yappy", "paypal"
  • Minimum items: 1 (when specified)
  • No duplicates: Each payment method can only appear once
  • Validation: Each specified method must be active on the account for the link’s environment (isTest: true uses your sandbox providers, isTest: false your production providers)
  • Default: When omitted, the payment page offers every payment method active on the account for that environment

"paypal" lets the customer pay with a PayPal account, or with a debit/credit card through PayPal’s guest checkout (no PayPal account needed).

For example, to offer card and PayPal only:

{
"concept": "Order #12345",
"amount": 4999,
"customerName": "Maria Lopez",
"isTest": true,
"paymentMethods": ["card", "paypal"]
}
  • Type: Integer (whole days; 1.5 or "7" are rejected)
  • Range: 1 to 60
  • Optional: When omitted, the link expires 3 days after it is created
  • Result: expiresAt in the response is the creation time plus expiresInDays × 24 hours

For example, a link that stays payable for 60 days:

{
"concept": "Invoice #2026-0042",
"amount": 125000,
"customerName": "Acme Corp",
"isTest": false,
"expiresInDays": 60
}

After expiresAt the link can no longer be paid: payment attempts are rejected with “Payment link has expired”. Links are not expired proactively and no webhook is sent on expiry. The stored status changes to expired when a listing (GET /v1/payment-links or the dashboard) returns the link on a page; that listing still shows pending, and later reads show expired. GET /v1/payment-links/{hashUrl} returns the stored status, so it keeps showing pending until then. Compare expiresAt with the current time to know whether a link is still payable.

Terminal window
curl -X POST "https://api-pay.zelta.dev/v1/payment-links" \
-H "X-API-Key: your-api-key-here" \
-H "Content-Type: application/json" \
-d '{
"concept": "Consulting Service",
"amount": 2500,
"customerName": "John Doe",
"isTest": true
}'
const paymentLinkData = {
concept: "Consulting Service",
amount: 2500, // $25.00 in cents
customerName: "John Doe",
isTest: true
};
const response = await fetch('https://api-pay.zelta.dev/v1/payment-links', {
method: 'POST',
headers: {
'X-API-Key': 'your-api-key-here',
'Content-Type': 'application/json'
},
body: JSON.stringify(paymentLinkData)
});
const result = await response.json();
console.log(result);
export default {
async fetch(request, env, ctx) {
const paymentLinkData = {
concept: "Consulting Service",
amount: 2500, // $25.00 in cents
customerName: "John Doe",
isTest: true
};
const response = await fetch('https://api-pay.zelta.dev/v1/payment-links', {
method: 'POST',
headers: {
'X-API-Key': env.ZELTA_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify(paymentLinkData)
});
const result = await response.json();
return new Response(JSON.stringify(result), {
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());
app.post('/payment-links', async (c) => {
const paymentLinkData = {
concept: "Consulting Service",
amount: 2500, // $25.00 in cents
customerName: "John Doe",
isTest: true
};
const response = await fetch('https://api-pay.zelta.dev/v1/payment-links', {
method: 'POST',
headers: {
'X-API-Key': c.env.ZELTA_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify(paymentLinkData)
});
const result = await response.json();
return c.json(result);
});
export default app;
import requests
import json
payment_link_data = {
"concept": "Consulting Service",
"amount": 2500, # $25.00 in cents
"customerName": "John Doe",
"isTest": True
}
headers = {
'X-API-Key': 'your-api-key-here',
'Content-Type': 'application/json'
}
response = requests.post(
'https://api-pay.zelta.dev/v1/payment-links',
headers=headers,
data=json.dumps(payment_link_data)
)
result = response.json()
print(result)
$paymentLinkData = [
'concept' => 'Consulting Service',
'amount' => 2500, // $25.00 in cents
'customerName' => 'John Doe',
'isTest' => true
];
$headers = [
'X-API-Key: your-api-key-here',
'Content-Type: application/json'
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://api-pay.zelta.dev/v1/payment-links');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($paymentLinkData));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$result = json_decode($response, true);
Terminal window
curl -X POST "https://api-pay.zelta.dev/v1/payment-links" \
-H "X-API-Key: your-api-key-here" \
-H "Content-Type: application/json" \
-d '{
"concept": "Order #12345 - Electronics Store",
"amount": 15999,
"customerName": "Alice Johnson",
"isTest": false,
"redirectUrl": "https://mystore.com/orders/12345/success",
"metadata": {
"orderId": "ORD-12345",
"customerId": "CUST-789",
"items": "laptop, mouse, keyboard",
"shippingMethod": "express",
"couponCode": "SAVE10"
}
}'
const paymentLinkData = {
concept: "Order #12345 - Electronics Store",
amount: 15999, // $159.99 in cents
customerName: "Alice Johnson",
isTest: false,
redirectUrl: "https://mystore.com/orders/12345/success",
metadata: {
orderId: "ORD-12345",
customerId: "CUST-789",
items: "laptop, mouse, keyboard",
shippingMethod: "express",
couponCode: "SAVE10"
}
};
const response = await fetch('https://api-pay.zelta.dev/v1/payment-links', {
method: 'POST',
headers: {
'X-API-Key': 'your-api-key-here',
'Content-Type': 'application/json'
},
body: JSON.stringify(paymentLinkData)
});
const result = await response.json();
console.log(result);
export default {
async fetch(request, env, ctx) {
const paymentLinkData = {
concept: "Order #12345 - Electronics Store",
amount: 15999, // $159.99 in cents
customerName: "Alice Johnson",
isTest: false,
redirectUrl: "https://mystore.com/orders/12345/success",
metadata: {
orderId: "ORD-12345",
customerId: "CUST-789",
items: "laptop, mouse, keyboard",
shippingMethod: "express",
couponCode: "SAVE10"
}
};
const response = await fetch('https://api-pay.zelta.dev/v1/payment-links', {
method: 'POST',
headers: {
'X-API-Key': env.ZELTA_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify(paymentLinkData)
});
const result = await response.json();
return new Response(JSON.stringify(result), {
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());
app.post('/payment-links', async (c) => {
const paymentLinkData = {
concept: "Order #12345 - Electronics Store",
amount: 15999, // $159.99 in cents
customerName: "Alice Johnson",
isTest: false,
redirectUrl: "https://mystore.com/orders/12345/success",
metadata: {
orderId: "ORD-12345",
customerId: "CUST-789",
items: "laptop, mouse, keyboard",
shippingMethod: "express",
couponCode: "SAVE10"
}
};
const response = await fetch('https://api-pay.zelta.dev/v1/payment-links', {
method: 'POST',
headers: {
'X-API-Key': c.env.ZELTA_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify(paymentLinkData)
});
const result = await response.json();
return c.json(result);
});
export default app;
payment_link_data = {
"concept": "Order #12345 - Electronics Store",
"amount": 15999, # $159.99 in cents
"customerName": "Alice Johnson",
"isTest": False,
"redirectUrl": "https://mystore.com/orders/12345/success",
"metadata": {
"orderId": "ORD-12345",
"customerId": "CUST-789",
"items": "laptop, mouse, keyboard",
"shippingMethod": "express",
"couponCode": "SAVE10"
}
}
headers = {
'X-API-Key': 'your-api-key-here',
'Content-Type': 'application/json'
}
response = requests.post(
'https://api-pay.zelta.dev/v1/payment-links',
headers=headers,
data=json.dumps(payment_link_data)
)
result = response.json()
print(result)
{
"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,
"redirectUrl": "https://myapp.com/success",
"metadata": {
"orderId": "ORD-001",
"service": "consulting"
},
"paymentMethods": ["card", "yappy"],
"requestCustomerEmail": true
}
},
"message": "Payment link created successfully",
"timestamp": "2024-01-15T10:30:00.000Z"
}
FieldTypeDescription
idstringUnique payment link identifier (a numeric string)
paymentLinkUrlstringURL for the payment link. Its last path segment is the hashUrl used by GET /v1/payment-links/{hashUrl} and cancel
customerNamestringName of the customer
customerEmailstring (optional)Customer’s email address. Omitted when not set
conceptstringDescription of what’s being paid for
amountintegerAmount in cents
statusstringPayment link status (always “pending” for new links)
createdAtstringISO 8601 timestamp of creation
expiresAtstringISO 8601 timestamp of expiration: expiresInDays days after creation, or 3 days when expiresInDays is omitted
cancelledAtstring (optional)ISO 8601 timestamp of cancellation. Omitted when not set
completedAtstring (optional)ISO 8601 timestamp of completion. Omitted when not set
isTestbooleanWhether this is a test payment
redirectUrlstring (optional)Custom redirect URL. Omitted when not set
metadataobject (optional)Custom metadata. Omitted when not set
paymentMethodsstring[] (optional)Allowed payment methods (e.g., ["card", "yappy", "paypal"]). Omitted when not set (every active method is offered)
requestCustomerEmailbooleanWhether the payment page requests the customer’s email

Optional fields are left out of the response when they have no value; they are never null. customerEmail is returned by create and GET /v1/payment-links/{hashUrl}, but not by the list endpoint.

CodeDescription
201Created - Payment link created successfully
400Bad Request - Validation error, amount below 100 cents, inactive payment method or no active payment provider
401Unauthorized - Missing X-API-Key header
403Forbidden - Account suspended (or, with a project key, project inactive)
404Not Found - Unknown API key (ERR_ACCOUNT_API_KEY_NOT_FOUND) or account not found
429Too Many Requests - Rate limit exceeded (plain-text body; wait Retry-After seconds)

Invalid or missing fields return 400 with the validation result instead of the usual error envelope: error.issues lists the failing fields (path) with a readable message and a machine-readable code. There is no message, error.code or timestamp at the top level. An unknown field produces one unrecognized_keys issue with path: [] and the field names in keys. 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.

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

An amount from 1 to 99 cents (see Amount):

{
"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"
},
"timestamp": "2024-01-15T10:30:00.000Z"
}

Returned when no payment provider is enabled for the link’s environment: an isTest: true link needs an enabled sandbox provider, an isTest: false link an enabled production provider.

{
"success": false,
"message": "No active payment provider found",
"error": {
"code": "ERR_NO_ACTIVE_PAYMENT_PROVIDER"
},
"timestamp": "2024-01-15T10:30:00.000Z"
}

A missing field reports "Required"; an empty concept reports "Concept is required".

{
"success": false,
"error": {
"issues": [
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": ["concept"],
"message": "Required"
}
],
"name": "ZodError"
}
}
{
"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"
}
}
{
"success": false,
"error": {
"issues": [
{
"code": "custom",
"message": "Metadata cannot have more than 20 keys",
"path": ["metadata"]
}
],
"name": "ZodError"
}
}
{
"success": false,
"message": "Payment method(s) not active: yappy",
"error": {
"code": "ERR_INACTIVE_PAYMENT_METHOD"
},
"timestamp": "2024-01-15T10:30:00.000Z"
}
async function createPaymentLink(apiKey, data) {
try {
const response = await fetch('https://api-pay.zelta.dev/v1/payment-links', {
method: 'POST',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (response.status === 429) {
// Rate limited: plain-text body, wait Retry-After seconds
throw new Error(`Rate limited, retry in ${response.headers.get('Retry-After')}s`);
}
const result = await response.json();
if (!response.ok) {
// 400 validation errors carry error.issues and no message
throw new Error(result.message ?? JSON.stringify(result.error?.issues));
}
return result.data.paymentLink;
} catch (error) {
console.error('Error creating payment link:', error);
throw error;
}
}
// Usage
const paymentLink = await createPaymentLink('your-api-key', {
concept: 'Consulting Service',
amount: 2500,
customerName: 'John Doe',
isTest: true
});
console.log('Payment link created:', paymentLink);
export default {
async fetch(request, env, ctx) {
async function createPaymentLink(apiKey, data) {
try {
const response = await fetch('https://api-pay.zelta.dev/v1/payment-links', {
method: 'POST',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (response.status === 429) {
// Rate limited: plain-text body, wait Retry-After seconds
throw new Error(`Rate limited, retry in ${response.headers.get('Retry-After')}s`);
}
const result = await response.json();
if (!response.ok) {
// 400 validation errors carry error.issues and no message
throw new Error(result.message ?? JSON.stringify(result.error?.issues));
}
return result.data.paymentLink;
} catch (error) {
console.error('Error creating payment link:', error);
throw error;
}
}
// Usage
try {
const paymentLink = await createPaymentLink(env.ZELTA_API_KEY, {
concept: 'Consulting Service',
amount: 2500,
customerName: 'John Doe',
isTest: true
});
return new Response(JSON.stringify({ paymentLink }), {
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 createPaymentLink(apiKey: string, data: any) {
try {
const response = await fetch('https://api-pay.zelta.dev/v1/payment-links', {
method: 'POST',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (response.status === 429) {
// Rate limited: plain-text body, wait Retry-After seconds
throw new Error(`Rate limited, retry in ${response.headers.get('Retry-After')}s`);
}
const result = await response.json();
if (!response.ok) {
// 400 validation errors carry error.issues and no message
throw new Error(result.message ?? JSON.stringify(result.error?.issues));
}
return result.data.paymentLink;
} catch (error) {
console.error('Error creating payment link:', error);
throw error;
}
}
app.post('/create-payment-link', async (c) => {
try {
const paymentLink = await createPaymentLink(c.env.ZELTA_API_KEY, {
concept: 'Consulting Service',
amount: 2500,
customerName: 'John Doe',
isTest: true
});
return c.json({ paymentLink });
} catch (error) {
return c.json({ error: error.message }, 500);
}
});
export default app;
import requests
import json
def create_payment_link(api_key, data):
try:
headers = {
'X-API-Key': api_key,
'Content-Type': 'application/json'
}
response = requests.post(
'https://api-pay.zelta.dev/v1/payment-links',
headers=headers,
data=json.dumps(data)
)
if response.status_code == 429:
# Rate limited: plain-text body, wait Retry-After seconds
raise Exception(f"Rate limited, retry in {response.headers.get('Retry-After')}s")
result = response.json()
if not response.ok:
# 400 validation errors carry error.issues and no message
raise Exception(result.get('message') or result['error'].get('issues'))
return result['data']['paymentLink']
except Exception as error:
print(f'Error creating payment link: {error}')
raise
# Usage
payment_link = create_payment_link('your-api-key', {
'concept': 'Consulting Service',
'amount': 2500,
'customerName': 'John Doe',
'isTest': True
})
print('Payment link created:', payment_link)
async function createOrderPaymentLink(apiKey, order) {
const paymentLinkData = {
concept: `Order #${order.id} - ${order.storeName}`,
amount: order.totalAmount, // Already in cents
customerName: order.customerName,
isTest: process.env.NODE_ENV !== 'production',
redirectUrl: `${process.env.FRONTEND_URL}/orders/${order.id}/success`,
metadata: {
orderId: order.id,
customerId: order.customerId,
items: order.items.map(item => item.name).join(', '),
shippingMethod: order.shippingMethod,
couponCode: order.couponCode || null
}
};
return await createPaymentLink(apiKey, paymentLinkData);
}
// Usage
const order = {
id: 'ORD-12345',
storeName: 'Electronics Store',
totalAmount: 15999,
customerName: 'Alice Johnson',
customerId: 'CUST-789',
items: [
{ name: 'laptop' },
{ name: 'mouse' },
{ name: 'keyboard' }
],
shippingMethod: 'express',
couponCode: 'SAVE10'
};
const paymentLink = await createOrderPaymentLink('your-api-key', order);
console.log('Order payment link:', paymentLink);
def create_order_payment_link(api_key, order):
payment_link_data = {
'concept': f'Order #{order["id"]} - {order["store_name"]}',
'amount': order['total_amount'], # Already in cents
'customerName': order['customer_name'],
'isTest': os.getenv('FLASK_ENV') != 'production',
'redirectUrl': f"{os.getenv('FRONTEND_URL')}/orders/{order['id']}/success",
'metadata': {
'orderId': order['id'],
'customerId': order['customer_id'],
'items': ', '.join([item['name'] for item in order['items']]),
'shippingMethod': order['shipping_method'],
'couponCode': order.get('coupon_code')
}
}
return create_payment_link(api_key, payment_link_data)
# Usage
order = {
'id': 'ORD-12345',
'store_name': 'Electronics Store',
'total_amount': 15999,
'customer_name': 'Alice Johnson',
'customer_id': 'CUST-789',
'items': [
{'name': 'laptop'},
{'name': 'mouse'},
{'name': 'keyboard'}
],
'shipping_method': 'express',
'coupon_code': 'SAVE10'
}
payment_link = create_order_payment_link('your-api-key', order)
print('Order payment link:', payment_link)
async function createSubscriptionPaymentLink(apiKey, subscription) {
const paymentLinkData = {
concept: `${subscription.planName} - ${subscription.billingCycle}`,
amount: subscription.amount,
customerName: subscription.customerName,
isTest: process.env.NODE_ENV !== 'production',
redirectUrl: `${process.env.FRONTEND_URL}/subscriptions/${subscription.id}/success`,
metadata: {
subscriptionId: subscription.id,
customerId: subscription.customerId,
plan: subscription.planName,
billingCycle: subscription.billingCycle,
startDate: subscription.startDate,
trialEndsAt: subscription.trialEndsAt
}
};
return await createPaymentLink(apiKey, paymentLinkData);
}
// Usage
const subscription = {
id: 'SUB-456',
planName: 'Premium Plan',
billingCycle: 'monthly',
amount: 2999,
customerName: 'Bob Smith',
customerId: 'CUST-123',
startDate: '2024-01-15',
trialEndsAt: '2024-02-15'
};
const paymentLink = await createSubscriptionPaymentLink('your-api-key', subscription);
console.log('Subscription payment link:', paymentLink);
function validateAmount(dollars) {
const cents = Math.round(dollars * 100);
if (cents < 100) {
throw new Error('Amount must be at least $1.00');
}
if (cents > 1000000) {
throw new Error('Amount cannot exceed $10,000.00');
}
return cents;
}
// Usage
const amount = validateAmount(25.99); // Returns 2599
def validate_amount(dollars):
cents = round(dollars * 100)
if cents < 100:
raise ValueError('Amount must be at least $1.00')
if cents > 1000000:
raise ValueError('Amount cannot exceed $10,000.00')
return cents
# Usage
amount = validate_amount(25.99) # Returns 2599
function validateMetadata(metadata) {
if (!metadata || Object.keys(metadata).length === 0) {
throw new Error('Metadata cannot be empty');
}
if (Object.keys(metadata).length > 20) {
throw new Error('Metadata cannot have more than 20 keys');
}
for (const [key, value] of Object.entries(metadata)) {
if (key.length < 1 || key.length > 40) {
throw new Error('Metadata key must be 1-40 characters');
}
if (typeof value === 'string' && value.length > 255) {
throw new Error('Metadata string value cannot exceed 255 characters');
}
}
// Check total size
const jsonString = JSON.stringify(metadata);
if (new TextEncoder().encode(jsonString).byteLength > 8192) {
throw new Error('Metadata is too large');
}
return metadata;
}
// Usage
const metadata = validateMetadata({
orderId: 'ORD-001',
customerId: 12345,
isVip: true
});
def validate_metadata(metadata):
if not metadata or len(metadata) == 0:
raise ValueError('Metadata cannot be empty')
if len(metadata) > 20:
raise ValueError('Metadata cannot have more than 20 keys')
for key, value in metadata.items():
if len(key) < 1 or len(key) > 40:
raise ValueError('Metadata key must be 1-40 characters')
if isinstance(value, str) and len(value) > 255:
raise ValueError('Metadata string value cannot exceed 255 characters')
# Check total size
json_string = json.dumps(metadata)
if len(json_string.encode('utf-8')) > 8192:
raise ValueError('Metadata is too large')
return metadata
# Usage
metadata = validate_metadata({
'orderId': 'ORD-001',
'customerId': 12345,
'isVip': True
})

Always handle validation errors gracefully:

async function createPaymentLinkWithValidation(apiKey, data) {
try {
// Validate amount
if (data.amount < 100 || data.amount > 1000000) {
throw new Error('Amount must be between $1.00 and $10,000.00');
}
// Validate required fields
if (!data.concept || !data.customerName) {
throw new Error('Concept and customerName are required');
}
return await createPaymentLink(apiKey, data);
} catch (error) {
console.error('Validation error:', error.message);
throw error;
}
}

Always use test mode during development:

const isTest = process.env.NODE_ENV !== 'production';
const paymentLinkData = {
concept: 'Test Payment',
amount: 100,
customerName: 'Test User',
isTest: isTest
};

Use consistent metadata keys and appropriate data types:

const metadata = {
orderId: 'ORD-001', // String
customerId: 12345, // Number
isVip: true, // Boolean
notes: null // Null
};

Implement retry logic for network errors. Don’t retry 400 validation errors, and after a 429 wait the number of seconds in the Retry-After header:

async function createPaymentLinkWithRetry(apiKey, data, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await createPaymentLink(apiKey, data);
} catch (error) {
if (attempt === maxRetries - 1) {
throw error;
}
// Exponential backoff
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
}
}

Use test mode for development and testing:

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

Test validation rules:

// Test minimum amount
const minAmountTest = {
concept: 'Test',
amount: 50, // Below minimum
customerName: 'Test',
isTest: true
};
// Test maximum amount
const maxAmountTest = {
concept: 'Test',
amount: 2000000, // Above maximum
customerName: 'Test',
isTest: true
};

Now that you understand payment link creation, explore these guides: