Skip to content
API Reference

Account Balance & Orders API

Keep a prepaid balance in USD or EUR on your account and buy bandwidth from it through the API, without a card payment for each order. This page covers the balance, top-ups, price tiers, quotes and orders.

How the Balance Works

  • Each account has two separate balances, one in USD and one in EUR. There is no conversion between them: an order in USD is paid from the USD balance, an order in EUR from the EUR balance. EUR list prices are 10% below the USD prices.
  • You add funds by card: through a Stripe Checkout page, or by charging a card saved to your account. VAT is added at checkout where applicable, and the balance is credited the net amount you chose.
  • Orders paid from the balance are charged instantly, with no card prompt per order. The order total is the list price before VAT, the same as GET /quote returns.
  • Bandwidth bought from the balance is credited to your account like any other purchase. It shows up in GET /api/v1/usage and in the dashboard, and it does not expire.
  • ZentisLabs sells to businesses only. Top-ups and orders return 403 BUSINESS_CONFIRMATION_REQUIRED until your business status is confirmed in the dashboard (once per account).

Money amounts are integers in minor units (cents): 4010 means 40.10. Fields named formatted carry the same amount as display text, and perGb prices are decimal numbers in major units. Currencies are written in lowercase: usd or eur.

Authentication

GET /products and GET /quote are public price data and need no key. All other endpoints on this page use your API key, sent in the X-API-Key header (or as Authorization: Bearer YOUR_API_KEY), the same as the rest of the API. Base URL: https://api.zentislabs.com/api/v1. Any key on the account can spend its balance, so give each integration its own named key and revoke a key you no longer use (Authentication).

Errors use the standard format, with a machine-readable code:

json
{
"success": false,
"error": "ZentisLabs sells to businesses only. Please confirm in your dashboard that you are buying as a business.",
"code": "BUSINESS_CONFIRMATION_REQUIRED"
}

Branch on code and the HTTP status, not on the error text, which can change.

Get Balance

GET/api/v1/wallet

Returns both balances, the limits that apply to your account, and how much was spent through the API today. Read the limits from here rather than hard-coding them.

No parameters.

bash
curl "https://api.zentislabs.com/api/v1/wallet" \
-H "X-API-Key: YOUR_API_KEY"
json
{
"success": true,
"data": {
"enabled": true,
"balances": {
"usd": { "cents": 4010, "formatted": "$40.10" },
"eur": { "cents": 0, "formatted": "€0.00" }
},
"limits": {
"topupMinCents": 500,
"topupMaxCents": 200000,
"balanceMaxCents": 1000000,
"dailyApiSpendCapCents": 200000
},
"apiSpentTodayCents": { "usd": 5990, "eur": 0 }
}
}

Errors

401
Missing or invalid API key, or the account is suspended.
429
More than 3,000 requests in a minute from this API key. Wait for Retry-After seconds.

List Balance Transactions

GET/api/v1/wallet/transactions

Every change to either balance, one entry per change. deltaCents is signed (negative for money spent) and balanceAfterCents is the balance in that currency right after the change.

Parameters

limit
query · integer · optional
Number of entries per page, for example 25.
cursor
query · string · optional
The nextCursor value from the previous page. Omit it for the first page.
kindMeaning
topupFunds added by card.
purchaseAn order paid from the balance (orderId is set).
purchase_refundAn order that could not be delivered, paid back to the balance.
topup_reversalA top-up taken back after its card payment was reversed.
admin_adjustmentA correction made by ZentisLabs support.
bash
curl "https://api.zentislabs.com/api/v1/wallet/transactions?limit=25" \
-H "X-API-Key: YOUR_API_KEY"
json
{
"success": true,
"data": [
{
"id": "8c1d0f4a-2b6e-4f3a-9d7c-5e2a1b0c9f84",
"currency": "usd",
"deltaCents": -5990,
"balanceAfterCents": 4010,
"kind": "purchase",
"description": "Residential Basic 10 GB",
"orderId": "3f2b6c1e-8d4a-4b7e-9a51-2c0e7f6d9b13",
"createdAt": "2026-11-09T10:15:02.000Z"
},
{
"id": "1a7e3c9b-5d2f-4e8a-b6c4-0f9d2e7a3b51",
"currency": "usd",
"deltaCents": 10000,
"balanceAfterCents": 10000,
"kind": "topup",
"description": "Funds added (card)",
"orderId": null,
"createdAt": "2026-11-09T10:02:41.000Z"
}
],
"nextCursor": null
}

When nextCursor is not null, pass it as cursor to fetch the next page.

Errors

401
Missing or invalid API key, or the account is suspended.
429
More than 3,000 requests in a minute from this API key. Wait for Retry-After seconds.

Add Funds

POST/api/v1/wallet/topup

Adds funds to one balance. Without paymentMethodId the response is a Stripe Checkout link: open it in a browser and pay there. With the ID of a card saved to your account the card is charged directly, and the response says succeeded. If that card needs authentication (3-D Secure), the response falls back to a Checkout link.

Parameters

amount
body · number · one of two
Amount in major units, up to 2 decimals, for example 100 or 25.50.
amountCents
body · integer · one of two
The same amount in cents, for example 10000. Send amount or amountCents.
currency
body · string · optional
usd (default) or eur. The balance to credit.
paymentMethodId
body · string · optional
A card saved to your account (pm_...). Omit it to get a Checkout link.

Each top-up is 5.00 to 2,000.00, and the balance after it may not exceed 10,000.00 in that currency. VAT is added at checkout where applicable; the balance is credited the amount you sent, before VAT.

bash
# Pay on a Stripe Checkout page
curl -X POST "https://api.zentislabs.com/api/v1/wallet/topup" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 100, "currency": "usd"}'
# Charge a saved card
curl -X POST "https://api.zentislabs.com/api/v1/wallet/topup" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 100, "currency": "usd", "paymentMethodId": "pm_XXXXXXXX"}'
json
// Checkout: open checkoutUrl in a browser to pay
{
"success": true,
"data": {
"status": "checkout",
"checkoutUrl": "https://checkout.stripe.com/c/pay/..."
}
}
// Saved card charged, balance credited
{
"success": true,
"data": {
"status": "succeeded",
"creditedCents": 10000,
"currency": "usd",
"balance": { "cents": 10000, "formatted": "$100.00" }
}
}

After a Checkout payment the balance is credited a few seconds later. Poll GET /wallet every few seconds until it changes; Stripe returns the browser to the billing page of the dashboard.

Errors

400
Amount below 5.00 or above 2,000.00, more than 2 decimals, unknown currency, or the balance would exceed 10,000.00.
403BUSINESS_CONFIRMATION_REQUIRED
Business status is not confirmed yet. Confirm it once in the dashboard, then retry.
429PURCHASE_RATE_LIMITED
More than 10 purchase calls in a minute or 60 in an hour on this account. Retry later.
401
Missing or invalid API key, or the account is suspended.
429
More than 3,000 requests in a minute from this API key. Wait for Retry-After seconds.

List Products and Price Tiers

GET/api/v1/products

The bandwidth products you can order, whether each one can be bought right now, and the per-GB price tiers in both currencies. The tier that applies is the highest minGb at or below the ordered amount.

No parameters.

bash
curl "https://api.zentislabs.com/api/v1/products"
json
// Abridged: light, advanced, mobile and datacenter follow in the same shape
{
"success": true,
"data": {
"currencies": ["usd", "eur"],
"products": [
{
"plan": "basic",
"name": "Residential Basic",
"unit": "GB",
"purchasable": true,
"availability": { "state": "AVAILABLE", "message": "" },
"maxGb": 10000,
"tiers": [
{ "minGb": 1, "perGb": { "usd": 6.59, "eur": 5.931 } },
{ "minGb": 5, "perGb": { "usd": 6.23, "eur": 5.607 } },
{ "minGb": 10, "perGb": { "usd": 5.99, "eur": 5.391 } },
{ "minGb": 25, "perGb": { "usd": 5.39, "eur": 4.851 } },
{ "minGb": 50, "perGb": { "usd": 4.91, "eur": 4.419 } },
{ "minGb": 100, "perGb": { "usd": 4.43, "eur": 3.987 } },
{ "minGb": 250, "perGb": { "usd": 3.95, "eur": 3.555 } },
{ "minGb": 500, "perGb": { "usd": 3.59, "eur": 3.231 } },
{ "minGb": 1000, "perGb": { "usd": 3.30, "eur": 2.97 } }
]
}
],
"note": "Prices are per GB before VAT. The per-GB price of the highest tier at or below the amount applies to the whole amount; amounts are rounded to 1 MB (0.001 GB). GET /api/v1/quote returns the exact total in cents."
}
}

Plans: light, basic, advanced (residential), mobile and datacenter. When purchasable is false, availability.message says why, and orders for that plan return 409 PRODUCT_UNAVAILABLE. Prices are before VAT.

Errors

429
More than 3,000 requests in a minute from your IP address. Wait for Retry-After seconds.

Get a Quote

GET/api/v1/quote

The exact amount an order would take from the balance. It uses the same formula as POST /orders and does not reserve anything.

Parameters

plan
query · string · required
light, basic, advanced, mobile or datacenter.
gb
query · number · required
Gigabytes. Decimals are allowed and rounded to whole megabytes. At most maxGb from GET /products.
currency
query · string · optional
usd (default) or eur.
bash
curl "https://api.zentislabs.com/api/v1/quote?plan=basic&gb=10&currency=usd"
json
{
"success": true,
"data": {
"plan": "basic",
"gb": 10,
"currency": "usd",
"perGb": 5.99,
"totalCents": 5990,
"formatted": "$59.90"
}
}
// The same order in EUR: "perGb": 5.391, "totalCents": 5391, "formatted": "€53.91"

Errors

400
Unknown plan or currency, or gb missing, not a positive number, or above maxGb.
429
More than 3,000 requests in a minute from your IP address. Wait for Retry-After seconds.

Place an Order

POST/api/v1/orders

Buys bandwidth and pays for it from the balance in the order's currency. The bandwidth is credited to your account before the response is sent. Requires an Idempotency-Key header (see Idempotency).

Parameters

Idempotency-Key
header · string · required
8 to 128 characters: letters, digits and _ - : . Use a new UUID for each new order. May instead be sent as idempotencyKey in the body.
plan
body · string · required
light, basic, advanced, mobile or datacenter.
gb
body · number · required
Gigabytes to buy. Decimals are allowed and rounded to whole megabytes.
currency
body · string · optional
usd (default) or eur. The balance that pays.
bash
curl -X POST "https://api.zentislabs.com/api/v1/orders" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-4b7d-4e3a-8c05-d2b9e1f7a634" \
-d '{"plan": "basic", "gb": 10, "currency": "usd"}'
json
{
"success": true,
"data": {
"orderId": "3f2b6c1e-8d4a-4b7e-9a51-2c0e7f6d9b13",
"plan": "basic",
"gb": 10,
"chargedCents": 5990,
"currency": "usd",
"balanceAfter": { "cents": 4010, "formatted": "$40.10" },
"status": "completed",
"replayed": false
}
}

Errors

400IDEMPOTENCY_KEY_REQUIRED
No Idempotency-Key header (or idempotencyKey in the body). Nothing charged.
400IDEMPOTENCY_KEY_INVALID
The key is not 8 to 128 allowed characters. Nothing charged.
400
Unknown plan, or gb not a positive number or above maxGb. Nothing charged.
400UPSTREAM_STOCK_LIMITED
Not enough capacity for this amount right now. Nothing charged. Try a smaller amount or later.
402INSUFFICIENT_BALANCE
The balance in this currency is below the order total. Nothing charged. Add funds first.
403BUSINESS_CONFIRMATION_REQUIRED
Business status is not confirmed yet. Confirm it once in the dashboard, then retry.
409PRODUCT_UNAVAILABLE
The plan cannot be bought right now (see availability in GET /products). Nothing charged.
409IDEMPOTENCY_KEY_REUSED
This key was already used for an order with a different plan, gb or currency.
429DAILY_SPEND_CAP_REACHED
This order would take today's API spending in this currency above the daily cap. Nothing charged.
429PURCHASE_RATE_LIMITED
More than 10 purchase calls in a minute or 60 in an hour on this account. Retry later.
502DELIVERY_FAILED
The bandwidth could not be delivered. The order is marked failed and the amount is returned to the balance automatically.
401
Missing or invalid API key, or the account is suspended.
429
More than 3,000 requests in a minute from this API key. Wait for Retry-After seconds.

When delivery fails

On 502 DELIVERY_FAILED the order status is failed and a purchase_refund entry puts the charged amount back on the balance. No bandwidth is added. To try again, send the order with a new Idempotency-Key.

Get an Order

GET/api/v1/orders/:id

One order on your account, paid from the balance or by card.

Parameters

id
path · string · required
The orderId returned by POST /orders.
bash
curl "https://api.zentislabs.com/api/v1/orders/3f2b6c1e-8d4a-4b7e-9a51-2c0e7f6d9b13" \
-H "X-API-Key: YOUR_API_KEY"
json
{
"success": true,
"data": {
"orderId": "3f2b6c1e-8d4a-4b7e-9a51-2c0e7f6d9b13",
"plan": "basic",
"gb": 10,
"chargedCents": 5990,
"currency": "usd",
"status": "completed",
"paidVia": "wallet",
"createdAt": "2026-11-09T10:15:02.000Z"
}
}

status is one of completed, failed, refunded, pending or canceled. paidVia is wallet or card.

Errors

404
No order with this ID on your account.
401
Missing or invalid API key, or the account is suspended.
429
More than 3,000 requests in a minute from this API key. Wait for Retry-After seconds.

Idempotency

A network error or timeout can leave you unsure whether an order went through. The Idempotency-Key header on POST /orders makes a retry safe:

  • The header is required: 8 to 128 characters, letters, digits and _ - : .
  • Generate a fresh UUID for each new order, and keep it until you have a final answer for that order.
  • Retrying with the same key returns the original order with "replayed": true. The balance is never charged twice for one key.
  • Sending a used key with a different plan, gb or currency returns 409 IDEMPOTENCY_KEY_REUSED.
  • Retry with the same key after timeouts, connection errors and 5xx responses other than 502 DELIVERY_FAILED.
typescript
import { randomUUID } from "node:crypto";
const API = "https://api.zentislabs.com/api/v1";
const key = randomUUID(); // one key per order
async function placeOrder(attempt = 0): Promise<any> {
try {
const res = await fetch(`${API}/orders`, {
method: "POST",
headers: {
"X-API-Key": process.env.ZENTISLABS_API_KEY!,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({ plan: "basic", gb: 10, currency: "usd" }),
});
if ([500, 503, 504].includes(res.status) && attempt < 4) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (attempt >= 4) throw err;
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
return placeOrder(attempt + 1); // same key: never charged twice
}
}
const result = await placeOrder();
console.log(result.success ? result.data : `${result.code}: ${result.error}`);

Limits

LimitValueWhen exceeded
Top-up amount5.00 to 2,000.00 per top-up400
Maximum balance10,000.00 per currency400 on the top-up
API spending2,000.00 per currency per day (default)429 DAILY_SPEND_CAP_REACHED
API requests3,000 per minute per API key429 with Retry-After
Purchase calls10 per minute and 60 per hour per account429 PURCHASE_RATE_LIMITED
  • The daily spending cap applies to orders placed with an API key only, so a leaked key cannot empty the balance in one day. Orders placed in the dashboard are not capped. The current cap is limits.dailyApiSpendCapCents and today's total is apiSpentTodayCents, both in GET /wallet.
  • Purchase calls are POST /wallet/topup and POST /orders. They count against one per-account limit, in addition to the per-key request limit (Rate Limits).

Typical Flow

  1. GET /products: check the plan is purchasable and read the price tiers.
  2. GET /quote: get the exact total, and compare it with the balance from GET /wallet.
  3. POST /orders with a new Idempotency-Key: pay from the balance.
  4. GET /orders/:id: confirm the status later, for example from a reconciliation job.
python
import time
import uuid
import requests
API = "https://api.zentislabs.com/api/v1"
HEADERS = {"X-API-Key": "YOUR_API_KEY"}
ORDER = {"plan": "basic", "gb": 10, "currency": "usd"}
# 1. Is the plan purchasable?
products = requests.get(f"{API}/products", headers=HEADERS, timeout=30).json()["data"]["products"]
plan = next(p for p in products if p["plan"] == ORDER["plan"])
if not plan["purchasable"]:
raise SystemExit(plan["availability"]["message"])
# 2. Exact total, and does the balance cover it?
quote = requests.get(f"{API}/quote", headers=HEADERS, params=ORDER, timeout=30).json()["data"]
wallet = requests.get(f"{API}/wallet", headers=HEADERS, timeout=30).json()["data"]
if wallet["balances"][ORDER["currency"]]["cents"] < quote["totalCents"]:
raise SystemExit(f"Balance too low for {quote['formatted']}")
# 3. Place the order. Retries reuse the same key, so it is charged at most once.
key = str(uuid.uuid4())
response = None
for attempt in range(5):
try:
response = requests.post(f"{API}/orders", json=ORDER, timeout=60,
headers={**HEADERS, "Idempotency-Key": key})
if response.status_code not in (500, 503, 504):
break
except requests.RequestException:
pass
time.sleep(2 ** attempt)
if response is None:
raise SystemExit("No response; retry later with the same key")
result = response.json()
if not result["success"]:
raise SystemExit(f"{response.status_code} {result.get('code')}: {result['error']}")
order = result["data"]
print("Order", order["orderId"], "paid", order["chargedCents"], "balance", order["balanceAfter"]["formatted"])
# 4. Later: check the order
status = requests.get(f"{API}/orders/{order['orderId']}", headers=HEADERS, timeout=30).json()["data"]["status"]
print("Status:", status)