Getting started
Introduction
Most integrations need two things:
- Create an invoice for an amount in dollars and send the customer to its
hosted_url. They pick a coin, pay, and come back to yourredirect_url. - Listen for webhooks. We POST
invoice.paidonce the payment has enough confirmations. That is the moment to ship the order.
That covers one-off sales. There are three ways to get paid, and you can use any mix of them:
- Buy. One payment for one order: an invoice and the hosted checkout.
- Subscriptions. A plan that renews. Customers pay ahead, and we email a renewal invoice before their paid time runs out.
- Wallet. For casinos, exchanges and apps with balances: each user gets permanent deposit addresses, and you send them payouts from your balance. The casino & wallet guide walks through the whole loop.
// 1. on checkout: create an invoice and send the customer to it const res = await fetch("https://z5.gg/api/v1/invoices", { method: "POST", headers: { Authorization: `Bearer ${process.env.ZEROFIVE_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ amount: "49.00", order_id: order.id }), }); const invoice = await res.json(); redirect(invoice.hosted_url); // 2. on your webhook endpoint: fulfil when it's paid if (event.type === "invoice.paid") fulfil(event.data.invoice.order_id);
Live demo
The real hosted pages, from a demo store in test mode. Nothing is charged and no coins move: press the simulate button and the payment confirms block by block, exactly as your customers will see it, receipt included.
Authentication
Every request carries a secret key in the Authorization header as a bearer token. Create keys in Dashboard → Developers. A key is shown once when you create it, so store it somewhere safe; you can revoke it at any time.
sk_test_…keys work on test data only: test invoices, test balances, simulated payments.sk_live_…keys move real money. Only the account owner can create them, after confirming their email, and every new live key (or change to one) is emailed to the owners.
A key belongs to the person who made it: it stops working if they leave the team or their account is disabled. The only endpoint that needs no key is GET /v1/networks.
curl https://z5.gg/api/v1/balances \ -H "Authorization: Bearer sk_test_4eC39HqLyjWDarjtT1zdp7dc…"
Permissions
Every key can read everything in its mode. Anything that creates or changes something needs the matching permission, picked when you create the key and changed later by the owner under the key's Access. New keys start with Invoices only. A key with nothing ticked is read-only: the safe kind to hand an accountant or a reporting tool (it can still make exports). A request the key isn't allowed to make gets a 403 with type permission.
Permissions
InvoicesinvoicesPayouts and refundspayoutsWithdrawalswithdrawalsPayout rulesrulesIP allowlist and coins
- Allowed IPs. A key can be limited to up to 50 IPv4 addresses, IPv4 ranges such as
203.0.113.0/24, or IPv6 addresses. A request from anywhere else gets a403permissionerror that names the IP it came from. - Accepted coins. A key can be limited to some coins, e.g. one site's key only takes USDT on Tron and Base. Invoices, tips and customers made with it only offer those, inside what your store accepts under Settings → Payments. They keep that set even if you edit the key later.
Test mode
Test and live are two separate worlds with the same API. Everything made with a test key (invoices, customers, payments, balances, withdrawals) is invisible to live keys and the other way round. The dashboard has a test/live switch that shows one or the other.
Test invoices get real-looking addresses, but nothing on chain is watched for them. Instead the hosted checkout shows a Simulate the payment panel (exact, partial or overpayment). A simulated payment confirms block by block over about 20 seconds, credits your test balance minus the fee, fires your webhooks and sends the emails, exactly like a real one. Its simulated is true and its tx_hash starts with sim_. Customer deposits can be simulated the same way from the customer's page in the dashboard or their hosted deposit page.
Test payouts, refunds and withdrawals are marked sent within a few seconds with a fake sim_… hash. Your payout rules still apply, so you can try the review flow. The live-only safeguards don't: sanctions screening, saved payout addresses and the platform's review of large amounts.
Test emails only go to your own people: your team and the addresses you added and confirmed under Settings → Emails → Recipients. A receipt for a test customer with any other address isn't sent; it's kept in the email log on that page, where you can open it and see exactly what the customer would get. Test emails are marked [TEST] and capped at 100 a day per store.
Errors
Errors use ordinary HTTP status codes and always have the same body. The message is written to be shown to a person, e.g. how much balance you have when a payout is too big. Branch on type and the status code, not on the message text.
Error types
invalid_request400authentication401permission403store_suspended403not_found404rate_limited429region_restricted451server500{
"error": {
"type": "invalid_request",
"message": "amount must be between 0.50 and 1,000,000.00 USD."
}
}Pagination
List endpoints return the newest first as {"object": "list", "data": [...], "has_more": true}. Pass limit (1–100, default 20) and, for the next page, starting_after with the id of the last object you got. Keep going while has_more is true. Payments are ordered by when they were first seen, everything else by when it was created.
Every list takes a date range: created[gte], created[gt], created[lte] and created[lt], each an ISO 8601 date or time (2026-10-01, 2026-10-01T12:00:00Z) or Unix seconds. /v1/invoices also filters by status, customer and order_id; /v1/payments by status, invoice, customer, network and asset.
The few lists that return everything at once (/v1/balances, /v1/networks, a customer's /addresses) have the same shape with has_more: false.
curl "https://z5.gg/api/v1/invoices?limit=50&starting_after=inv_3fTq9WbYk2Lm8nPx4RcZ" \ -H "Authorization: Bearer $ZEROFIVE_KEY"
Rate limits & retries
- Rate limit. 300 requests a minute per key. Past that you get a
429rate_limitederror; wait a few seconds and retry. For payment status, prefer webhooks over tight polling loops. - Safe retries for money.
POST /v1/payouts,POST /v1/withdrawalsandPOST /v1/invoices/:id/refundtake anexternal_id, unique per store and mode. Sending the same request again returns the first object instead of paying twice. Reusing anexternal_idfor a different amount, coin, network, address, customer or invoice is refused with a400. - Invoices. An
order_idcan only be used once per store and mode; a second invoice with it gets a400. So a retry after a timeout can't make a duplicate; if it gets that400, the first one went through and is inGET /v1/invoices. - Customers.
POST /v1/customerswith anexternal_idthat already exists returns that customer, so it's safe to call on every login.
Accepting payments
Hosted checkout
Every invoice has a hosted_url (https://z5.gg/pay/inv_…). Send the customer there, or show it as a link or QR code (try it in the live demo). The page:
- lets the customer pick a coin and network;
- locks a quote for 30 minutes and shows the invoice's own deposit address with a QR code;
- tracks the payment live: detected, confirming (x of y), paid;
- sends the customer to your
redirect_urlwhen it's paid, and emails them a receipt if you setcustomer_email.
Paid invoices also have a public receipt at receipt_url that the customer can print, and download as a PDF at receipt_url + /pdf. Short payments, overpayments and late payments are covered in the payment lifecycle.
Payment lifecycle
What happens between "the customer picked a coin" and "the money is in your balance", and the edge cases you'll meet in production.
- Quote. Picking a coin locks a rate for 30 minutes (cut short by the invoice's own expiry, but always at least 10 minutes, so a quote taken just before the invoice expires can outlast it) and gives the invoice its own deposit address for that network. The invoice goes from
opentopending. Amounts are rounded up to a precision people can type. - Detected. As soon as a transfer to the address is seen, the invoice is
confirmingandinvoice.payment_detectedfires. Don't ship yet. - Confirmed. Once it has enough confirmations (fewer for payments under $1,000, see networks), the payment is credited to your balance minus the fee and the invoice becomes
paidorpartially_paid.invoice.paidis the signal to fulfil.
Edge cases
Short paymentpartially_paidinvoice.partially_paid and emails the customer what's left. They can send the rest to the same address; payments add up, and the invoice turns paid once the total is covered. A partially paid invoice doesn't expire on its own.Overpaymentoverpaid: trueoverpaid. The whole amount is credited to your balance, and the customer can ask for the extra back themselves (see refunds).Quote ran outpendingLate paymentexpiredLarge paymentconfirmingconfirming for up to about 60 minutes after their confirmations, then are screened again before they're credited. See screening & holds.Flagged senderheld, declinedheld (payment.held), and the invoice stays unpaid. See screening & holds.Refunded invoicepaidPopup & embedded checkout
The same checkout can stay on your site. Add one script, then open an invoice's hosted_url in a popup over your page or put it inside the page itself. Coins, quotes, confirmations and receipts work exactly as on the full page.
<script src="https://z5.gg/js/z5.js"></script>Popup. Opens over your page; the customer can close it with the ×, Escape or a click outside. Any link with data-zerofive does this by itself. Zerofive.open returns { close } if you want to close it from code.
// a link: <a href="https://z5.gg/pay/inv_…" data-zerofive>Pay with crypto</a> // or from code, after your server created the invoice: const popup = Zerofive.open(invoice.hosted_url, { onPaid: ({ invoice }) => showThanks(invoice), // invoice is the id, e.g. "inv_3fTq…" onClose: ({ paid }) => { if (!paid) console.log("closed before paying") }, });
Embedded. Inside any element on your page. The frame grows and shrinks with the checkout, so there's never a scrollbar inside it. Zerofive.mount returns { unmount }.
// <div id="pay"></div> Zerofive.mount("#pay", invoice.hosted_url, { onStatus: (status) => console.log(status), // "pending", "confirming", "paid"… onPaid: ({ invoice, redirect_url }) => (location.href = "/thanks"), });
Options (both)
onPaidfunction{ invoice, redirect_url } when the invoice is paid. invoice is its id.onStatusfunctiononClosefunction{ paid } when it closes.onDepositfunction{ payment, status, network, asset, amount, usd } when a transfer shows up (confirming) and again when it's credited.An invoice's hosted_url, a tip page link or a customer's deposit_page_url can be opened this way (a deposit page shows the customer's permanent addresses with a coin and network picker, and their recent deposits). Without onPaid, the page goes to the invoice's redirect_url once it's paid. Either way, the browser is only a hint: confirm the payment on your server with the invoice.paid webhook or GET /v1/invoices/:id before you ship anything.
Your own payment page
Don't want our checkout at all? Pick the coin yourself and show the address, amount and QR code in your own design. Payments are still detected, confirmed and credited the same way, and you get the same webhooks.
One-off payments. Pass network and asset when you create the invoice (or later with POST /v1/invoices/:id/quote, which also gets a fresh rate once a quote runs out). The payment object then has everything to show:
curl https://z5.gg/api/v1/invoices \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{"amount": "25.00", "network": "tron", "asset": "USDT", "order_id": "A-1042"}'
{
"network": "tron",
"asset": "USDT",
"address": "TQn9Y2…kD7uWe",
"amount_due": "25",
"quote_expires_at": "2026-10-21T10:30:00.000Z",
"uri": "TQn9Y2…kD7uWe",
"qr_url": "https://z5.gg/pay/inv_…/qr"
}Show amount_due and address, and qr_url as an image (<img src="…">, an SVG with the Z5 mark in the middle). It opens the wallet with the amount filled in; add ?type=address for a code with just the address, for exchanges and wallets that can't read payment links. uri is the same payment link as text, for your own QR code or an "Open in wallet" button. It carries what's still owed:
bitcoin:,litecoin:,dogecoin:andbitcoincash:(BIP21,?amount=in coins)ethereum:(EIP-681 with the chain id, for ETH and tokens on every EVM network; amounts in base units)solana:(Solana Pay, withspl-token=for tokens) andton://transfer/(withjetton=for USDT)- Tron has no payment-link standard wallets agree on, so it's the plain address there.
Show the network's warning next to the address (e.g. "TRC-20 only", see networks), and watch status with webhooks or by polling the invoice. When quote_expires_at passes, get a fresh quote before showing the amount again.
Wallets and deposits. Each customer's permanent addresses come from GET /v1/customers/:id/addresses, each with its own uri and qr_url. Those never change, so you can show them anywhere, for as long as you like. Or skip building it: open the customer's deposit_page_url with Zerofive.open and listen for onDeposit.
Tips & donations
Turn on a tip page under Dashboard → Tips and your audience can tip or donate in crypto at https://z5.gg/tip/yourname: preset or custom amounts, a name, a message, an optional goal with a progress bar, and a list of recent supporters if you want one. Every tip is an invoice with source: "tip", so it lands in your balance like any payment and the supporter can get a receipt.
Ready-made: links, popup, embed
Prefill a link with ?amount=25, &name= and &message= (the link generator in the dashboard builds these, with a QR code); the supporter can still change everything. With z5.js the tip page opens as a popup or sits inside your own page:
<script src="https://z5.gg/js/z5.js"></script> <a href="https://z5.gg/tip/yourname?amount=10" data-zerofive>Tip $10</a> <!-- popup --> <div id="tips"></div> <script>Zerofive.mount("#tips", "https://z5.gg/tip/yourname")</script> <!-- inline -->
Your own tip page, no server
From a static site, post straight to your tip page's endpoint (open to any origin, no key). Your tip page must be turned on, and its rules apply: smallest tip, required name or message. Add network and asset and the response already has the address, the exact amount and a QR code to show in your own design; leave them out and send the supporter to hosted_url. Then poll status_url until status is paid. Errors here are a plain { "error": "…" }, and each visitor can start 20 tips per 10 minutes.
const tip = await fetch("https://z5.gg/api/tip/yourname", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ amount: 5, name: "Satoshi", message: "GG", network: "tron", asset: "USDT" }), }).then((r) => r.json()); // tip.payment: { address, amount_due, uri, qr_url, quote_expires_at, ... } img.src = tip.payment.qr_url; const { status } = await fetch(tip.status_url).then((r) => r.json()); // "pending" … "paid"
Add "test": true while you build: you get a test tip you can pay with the simulate button on its checkout.
From your server: the tips API
/api/v1/tipsParameters
amountstring | numberrequirednamestringmessagestringemailstringnetwork, assetstringredirect_urlstringmetadataobjectWorks with the tip page on or off, and your own rules apply (the page's required fields and minimum don't). A tip stays open for an hour. Returns the tip object (its id is an invoice id, so the invoice endpoints work on it too):
{
"id": "inv_7Hq2…",
"object": "tip",
"livemode": true,
"status": "paid",
"amount": "25.00",
"currency": "USD",
"name": "Satoshi",
"message": "Love the streams, keep going!",
"email": null,
"hidden": false,
"metadata": { "stream": "friday" },
"payment": {
"network": "tron",
"asset": "USDT",
"address": "TQ5v…",
"rate_usd": 1,
"amount_due": "25",
"amount_paid": "25",
"quote_expires_at": "2026-10-21T18:31:40.000Z",
"uri": "TQ5v…",
"qr_url": "https://z5.gg/pay/inv_7Hq2…/qr"
},
"hosted_url": "https://z5.gg/pay/inv_7Hq2…",
"receipt_url": "https://z5.gg/receipt/…",
"expires_at": "2026-10-21T19:01:40.000Z",
"paid_at": "2026-10-21T18:02:11.000Z",
"created_at": "2026-10-21T18:01:40.000Z"
}/api/v1/tipsYour tips, newest first. status=paid for the ones that came in; paginated like every list.
/api/v1/tips/:id/api/v1/tips/:id{ "hidden": true } keeps a tip off your tip page, the public feed and the overlay (or false to show it again).
Supporter lists, alerts and webhooks
GET https://z5.gg/api/tip/yourname/feed: recent paid tips as JSON ({ store, total, count, goal, tips: [{ id, name, message, amount, at }] }), open to any origin, for your own supporter list. Optionallimit(up to 50),since(milliseconds timestamp) andtest=1for test tips. Public when your tip page shows supporters; otherwise add your overlaykey. Hidden tips are left out.- Stream alerts: the overlay link from Dashboard → Tips, as a browser source in OBS or Streamlabs. Each new tip pops up with name, amount and message.
- Webhooks:
tip.receivedwith the tip object when a tip is paid, as well as the usualinvoice.paid.
Invoices
An invoice asks for an amount in US dollars. The customer chooses what to pay with on the hosted checkout, or you pick it for them. payment is null until a coin is picked; its amount_paid counts against the current quote. amount_paid_usd is everything received so far.
Statuses
openstatuspendingstatusconfirmingstatuspartially_paidstatuspaidstatusexpiredstatuscanceledstatus{
"id": "inv_3fTq9WbYk2Lm8nPx4RcZ",
"object": "invoice",
"livemode": false,
"status": "paid",
"amount": "49.00",
"currency": "USD",
"subtotal": "49.00",
"tax": null,
"customer_country": null,
"customer_tax_id": null,
"amount_paid_usd": "49.00",
"description": "Pro plan, 1 month",
"order_id": "A-1042",
"customer": null,
"customer_email": "[email protected]",
"subscription": null,
"metadata": { "plan": "pro" },
"payment": {
"network": "tron",
"asset": "USDT",
"address": "TQ5v…",
"rate_usd": 1,
"amount_due": "49",
"amount_paid": "49",
"quote_expires_at": "2026-10-20T10:42:00.000Z",
"uri": "TQ5v…",
"qr_url": "https://z5.gg/pay/inv_3fTq9WbYk2Lm8nPx4RcZ/qr"
},
"overpaid": false,
"hosted_url": "https://z5.gg/pay/inv_3fTq9WbYk2Lm8nPx4RcZ",
"receipt_url": "https://z5.gg/receipt/9Hk…",
"redirect_url": "https://shop.example.com/thanks",
"merchant": { "id": "mer_…", "name": "Sam's Shop" },
"expires_at": "2026-10-20T11:12:00.000Z",
"paid_at": "2026-10-20T10:14:31.000Z",
"created_at": "2026-10-20T10:12:00.000Z"
}/api/v1/invoicesCreates an invoice and returns it.
Parameters
amountstring | numberrequireddescriptionstringorder_idstringcustomerstringcustomer_emailstringcustomer_countrystringcustomer_tax_idstringredirect_urlstringnetworkstringassetstringmetadataobjectexpires_in_minutesnumbercurl https://z5.gg/api/v1/invoices \ -H "Authorization: Bearer $ZEROFIVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": "49.00", "description": "Pro plan, 1 month", "order_id": "A-1042", "customer_email": "[email protected]", "redirect_url": "https://shop.example.com/thanks", "metadata": { "plan": "pro" } }'
const invoice = await fetch("https://z5.gg/api/v1/invoices", { method: "POST", headers: { Authorization: `Bearer ${process.env.ZEROFIVE_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ amount: 49, order_id: "A-1042" }), }).then((r) => r.json()); console.log(invoice.hosted_url);
/api/v1/invoicesLists invoices, newest first, tips and subscription invoices included. Filter with status; paginate with limit and starting_after.
/api/v1/invoices/:idReturns one invoice. Poll this if you can't receive webhooks.
/api/v1/invoices/:id/quotePicks the coin for an invoice with network and asset (both required), or gets a fresh rate once a quote ran out. Works on open, pending and partially_paid invoices before they expire; a partly paid one is quoted for what's left. Asking again for the same coin while the quote holds returns it unchanged. Returns the invoice with payment filled in: address, amount, uri and qr_url. See your own payment page.
/api/v1/invoices/:id/cancelCancels an open or pending invoice. Invoices that already received money can't be canceled.
Subscriptions
Crypto can't be charged later the way a saved card can, so subscriptions here are paid ahead. A plan is a price per interval. A subscription is paid up to paid_through, and every paid invoice moves that date on by the periods it covered.
A subscription can be for several plans at once, each with a quantity (say Pro × 1 and Extra seat × 3), billed together on one invoice. These are its items. amount is one period of all of them together, and plan is the first item's plan.
- Create a subscription and send the customer to
pay_url. When that first payment confirms it becomesactive. - About three days before
paid_through(half a period on shorter plans) we email the customer a renewal invoice and send yousubscription.payment_due. - Unpaid by
paid_through, it'spast_dueand we remind them. After the plan'sgrace_daysit'scanceled.
Customers can pay several periods at once, at the plan's prepay discount, from their subscription page (manage_url). It also lets them cancel, and it's linked in every renewal email. Give access while the status is active or past_due.
Statuses
incompletestatusactivestatuspast_duestatuscanceledstatus{
"id": "sub_F74I3tdTjDDJ2HqlEPNs",
"object": "subscription",
"livemode": false,
"status": "active",
"plan": {
"id": "plan_HVeqUYWyAdcS6Rv2qgxC",
"object": "plan",
"livemode": false,
"name": "Pro plan",
"description": null,
"amount": "20.00",
"currency": "USD",
"interval": "month",
"interval_count": 1,
"prepay": [
{ "periods": 6, "percent_off": 10, "amount": "108.00" },
{ "periods": 12, "percent_off": 15, "amount": "204.00" }
],
"grace_days": 7,
"active": true,
"custom": false,
"switchable": true,
"created_at": "2026-10-01T09:00:00.000Z"
},
"items": [
// each item has the full plan object, as in "plan" above
{ "plan": { "id": "plan_HVeqUYWyAdcS6Rv2qgxC", "name": "Pro plan", … }, "quantity": 1 },
{ "plan": { "id": "plan_Q2xmT8bLwR4nVc7sKd9E", "name": "Extra seat", … }, "quantity": 3 }
],
"amount": "35.00",
"interval": "month",
"interval_count": 1,
"pending_change": null,
"customer": null,
"customer_email": "[email protected]",
"paid_through": "2027-04-20T22:19:05.000Z",
"periods_paid": 6,
"started_at": "2026-10-20T22:19:05.000Z",
"cancel_at_period_end": false,
"canceled_at": null,
"cancel_reason": null,
"latest_invoice": "inv_PVfbm7wQKnS55qsch1jz",
"pay_url": null,
"manage_url": "https://z5.gg/s/aEA1dMy6HfEz53AAKzh…",
"metadata": { "user": "42" },
"created_at": "2026-10-20T22:15:40.000Z"
}/api/v1/plansCreates a plan. The price can't change later: make a new plan and move people over.
Parameters
namestringrequiredamountstring | numberrequiredintervalstringrequiredinterval_countnumberprepayarraygrace_daysnumberdescriptionstringswitchablebooleanGET /api/v1/plans lists them (?active=true or false to filter), GET /api/v1/plans/:id gets one, and POST /api/v1/plans/:id changes the name, description, prepay options, grace_days or switchable, or archives it with { "active": false } (true brings it back). Archived plans take no new subscriptions; existing ones keep renewing.
Plans made from a custom price on a subscription have custom: true. They belong to that one subscription and are left out of the list unless you ask for ?custom=true.
curl https://z5.gg/api/v1/plans \ -H "Authorization: Bearer $ZEROFIVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Pro plan", "amount": "20.00", "interval": "month", "prepay": [ { "periods": 6, "percent_off": 10 }, { "periods": 12, "percent_off": 15 } ], "grace_days": 7 }'
/api/v1/subscriptionsCreates a subscription in incomplete with its first invoice. Send the customer to pay_url. Pass either plan (with an optional quantity) or items.
All items have to renew on the same period (same interval and interval_count), since they're billed together. An item with a price instead of a plan makes a custom plan just for this subscription. The prepay options on offer are the lengths every item's plan has.
Parameters
planstringquantitynumberitemsarraycustomer_emailstringrequiredcustomerstringperiodsnumberredirect_urlstringmetadataobjectcurl https://z5.gg/api/v1/subscriptions \ -H "Authorization: Bearer $ZEROFIVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "plan": "plan_HVeqUYWyAdcS6Rv2qgxC", "customer_email": "[email protected]", "periods": 6, "redirect_url": "https://app.example.com/billing", "metadata": { "user": "42" } }' # several plans, or a custom price just for this customer curl https://z5.gg/api/v1/subscriptions \ -H "Authorization: Bearer $ZEROFIVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "plan": "plan_HVeqUYWyAdcS6Rv2qgxC", "quantity": 1 }, { "plan": "plan_Q2xmT8bLwR4nVc7sKd9E", "quantity": 3 }, { "price": { "name": "Acme deal", "amount": "49.00", "interval": "month", "interval_count": 1 }, "quantity": 1 } ], "customer_email": "[email protected]" }'
/api/v1/subscriptionsLists subscriptions, newest first. Filter with status or customer. GET /api/v1/subscriptions/:id gets one.
/api/v1/subscriptions/:id/cancelStops renewals: it stays active with cancel_at_period_end: true until paid_through, then ends, and any open renewal invoice is canceled. Send { "at_period_end": false } to end it now; one that was never paid (incomplete) always ends now. Paid time isn't refunded automatically; refund the invoice if you want to. POST /api/v1/subscriptions/:id/resume undoes a cancel that hasn't happened yet.
/api/v1/subscriptions/:id/changeChanges what a subscription is for: another plan, more or fewer seats, or a custom price. Send items (same shape as when creating) or plan with quantity. Returns { subscription, invoice }, where invoice is the one to pay for the change, or null.
Parameters
itemsarrayplanstringquantitynumbertimingstringNow, while active. The unused part of the current paid time counts as credit, at what the customer actually paid per period (prepay discounts included). We create an invoice for the rest of the current period at the new price, minus that credit. Once it's paid the items change and the renewal date stays where it was. Under $0.50 due, it changes right away with no invoice.
A different period length (monthly to yearly, say) works the same way, except the invoice is the new plan's first full period minus the credit, and a new period starts when it's paid. If the credit is worth more than that whole period, switch at period_end instead.
The change invoice is open for up to 3 days, and never past the end of the paid time. If it isn't paid by then, the change lapses and nothing else happens. Asking for now on a cheaper plan with the same period length is refused, since paid time isn't refunded.
At period end. The next renewal invoice is for the new items, and the change happens when that renewal is paid.
Not paid yet or past due. An incomplete or past_due subscription changes straight away, and invoice is the invoice to pay for the new items.
Only one change waits at a time; a new one replaces it, and you'll see it in pending_change. Canceling the subscription drops a waiting change. One that's set to cancel at period end can't be changed until you resume it. Each step (scheduled, requested, applied, called off) sends subscription.updated.
curl https://z5.gg/api/v1/subscriptions/sub_F74I3tdTjDDJ2HqlEPNs/change \ -H "Authorization: Bearer $ZEROFIVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "plan": "plan_7KpWn3RtYc9LbXq2Md4S", "quantity": 1 }'
{
"subscription": {
"id": "sub_F74I3tdTjDDJ2HqlEPNs",
"object": "subscription",
"status": "active",
"amount": "5.00",
"paid_through": "2026-11-20T22:19:05.000Z",
"pending_change": {
"id": "chg_8YtRk2NqVb5LmXc3Wd7P",
"items": [{ "plan": { "id": "plan_7KpWn3RtYc9LbXq2Md4S", "name": "Pro", … }, "quantity": 1 }],
"timing": "now",
"amount_due": "6.67",
"credit": "3.33",
"invoice": "inv_Ux3Lr9TqZk6NwB2mHc8Y",
"pay_url": "https://z5.gg/pay/inv_Ux3Lr9TqZk6NwB2mHc8Y",
"created_at": "2026-10-30T22:19:05.000Z"
},
…
},
"invoice": { "id": "inv_Ux3Lr9TqZk6NwB2mHc8Y", "object": "invoice", "amount": "6.67", … }
}/api/v1/subscriptions/:id/change/cancelCalls off a waiting change (one waiting for its payment or for the next renewal) and its invoice if nothing was paid on it yet. Returns the subscription. Works while your store is suspended.
Customers & deposits
A customer has one permanent deposit address per address family: one EVM address serves Ethereum, Base, Arbitrum, Optimism, Polygon, BNB Chain, Avalanche, Plasma, Arc and Tempo; Tron, Bitcoin, Litecoin, Dogecoin, Bitcoin Cash, Solana and TON each have their own. Addresses are created the first time you ask for them.
Anything sent to them in a coin listed in the address's assets, any amount, any time, is credited to your balance minus the fee and reported as deposit.detected, then deposit.confirmed, with the customer's id. Transfers worth under ten cents are ignored as dust. If the customer has an email, they get a confirmation for each deposit.
A customer only gets addresses for networks your store (and the key that created them) accepts. Because one EVM address serves every EVM network, tell users which network to use: a coin sent on a network that's off isn't credited.
Each customer also gets a hosted deposit page at deposit_page_url (/d/<token>) listing their addresses with QR codes and their recent deposits. Link to it if you don't want to build your own.
/api/v1/customersParameters
emailstringnamestringexternal_idstringmetadataobject/api/v1/customersNewest first, paginated. ?external_id=user_812 finds a customer by your own id.
/api/v1/customers/:id/api/v1/customers/:id/addressesEvery accepted network as {network, name, assets, address, uri, qr_url}, in a list. Networks of one family share the address. qr_url is an SVG of the address, for your own deposit page.
/api/v1/customers/:id/addresses/:networkOne network, e.g. tron, btc, base (see networks), with the same fields plus confirmations_required, confirmations_required_small (deposits under $1,000) and a warning to show next to it. A network that isn't accepted for this customer gets a 400.
# create (or fetch, with the same external_id) curl https://z5.gg/api/v1/customers \ -H "Authorization: Bearer $ZEROFIVE_KEY" \ -H "Content-Type: application/json" \ -d '{"external_id": "user_812", "email": "[email protected]"}' # their USDT-on-Tron address curl https://z5.gg/api/v1/customers/cus_…/addresses/tron \ -H "Authorization: Bearer $ZEROFIVE_KEY"
{
"id": "cus_Qm4p…",
"object": "customer",
"livemode": false,
"email": "[email protected]",
"name": null,
"external_id": "user_812",
"metadata": {},
"deposit_page_url": "https://z5.gg/d/7Fh…",
"deposits_count": 3,
"deposits_usd": "250.00",
"created_at": "2026-10-20T09:00:00.000Z"
}Listing deposits
Deposits are payment objects that went to a customer's permanent address (not to an invoice, and not your own top-ups). Use these to reconcile or to show a user their history.
/api/v1/depositsFilter with customer and status (any payment status, usually confirming or credited); paginated.
/api/v1/customers/:id/deposits/api/v1/customers/:id/payoutsEverything sent to that customer, newest first: payouts, and refunds of invoices attached to them.
# every customer deposit, newest first curl "https://z5.gg/api/v1/deposits?limit=50" \ -H "Authorization: Bearer $ZEROFIVE_KEY" # one user's deposits and payouts curl https://z5.gg/api/v1/customers/cus_…/deposits -H "Authorization: Bearer $ZEROFIVE_KEY" curl https://z5.gg/api/v1/customers/cus_…/payouts -H "Authorization: Bearer $ZEROFIVE_KEY"
Payments
A payment is one on-chain transfer into one of your addresses: to an invoice, a customer's deposit address or your top-up address. It starts as Confirming and becomes Credited once it has the confirmations its network needs: fewer for payments under $1,000 (see networks), and confirmations_required says which applied. Payments from the same sender, or into the same address, within a few hours count together for that, so one amount sent in pieces waits like it would in one go. Its amount_usd is fixed when it is first seen. fee and net_amount are set once it's credited; net_amount is what reached your balance.
Statuses
confirmingstatuscreditedstatusheldstatusdeclinedstatusdroppedstatusignoredstatusA payment to an invoice in another coin, on another chain, or after its quote ran out still counts, at its dollar value; mismatch lists which of chain, coin and late applied. It's in your balance either way. When the invoice can't settle it on its own, needs_attention says why: short (it didn't cover the invoice), canceled, refunded or extra (the invoice was already paid), and payment.needs_attention fires. You decide under Payments in the dashboard: mark the invoice paid, refund the payment, or keep it; resolution then says which.
/api/v1/paymentsFilter with invoice, customer or status; paginated.
/api/v1/payments/:id{
"id": "pay_7Yc2…",
"object": "payment",
"livemode": true,
"status": "credited",
"invoice": "inv_3fTq…",
"customer": null,
"network": "tron",
"asset": "USDT",
"amount": "49",
"fee": "0.245",
"net_amount": "48.755",
"amount_usd": "49.00",
"address": "TQ5v…",
"from": "TLa2…",
"tx_hash": "a3f9…",
"explorer_url": "https://tronscan.org/#/transaction/a3f9…",
"confirmations": 6,
"confirmations_required": 6,
"simulated": false,
"detected_at": "2026-10-20T10:13:58.000Z",
"credited_at": "2026-10-20T10:14:31.000Z",
"mismatch": [],
"needs_attention": null,
"resolution": null,
"dropped_reason": null
}Taxes
Optional. Under Settings → Taxes, list the countries where you charge VAT, GST or sales tax and the rate for each (there's a button for the EU's standard VAT rates), a rate for everywhere else if you want one, and the business details your receipts need: legal name, address and tax ID.
Choose whether your prices include tax (a $49 invoice is $49, tax inside) or have it added on top ($49 plus tax). Pass customer_country when you create the invoice and the tax is worked out straight away. Leave it out and the checkout asks the customer first: until then tax.pending is true and no coin can be picked.
amount is always what the customer pays. The checkout, the receipt and the receipt email show the subtotal and tax line, and the receipt carries your details and the buyer's tax ID if they gave one. We apply your rates; what you owe and where is for you and your accountant.
// with taxes on (prices include tax, DE at 19%) POST /api/v1/invoices { "amount": "49.00", "customer_country": "DE" } { "amount": "49.00", // what the customer pays "subtotal": "41.18", "tax": { "name": "VAT", "rate": 19, "amount": "7.82", "inclusive": true, "country": "DE", "pending": false }, "customer_country": "DE", "customer_tax_id": null }
Your money
Balances
You hold a balance per coin and network, in the coin you were paid in. There is no conversion: 49 USDT on Tron stays 49 USDT on Tron (minus the fee) until you withdraw it. amount_usd is today's value for reference (null if there's no price right now), and the list is sorted by it, largest first. fee_free_liquidity is the part that came from top-ups. Coins with a zero balance are left out.
New stores: in your first 30 days of taking live payments, customer payments can be sent out 72 hours after they arrive (your first week) or 24 hours after (to day 30). on_hold is the part of amount still waiting, and on_hold_until when the next part frees up. Withdrawals, payouts and refunds can use the rest, and top-ups are never held. Asking for more returns a 400 that says how much is on hold. Test mode has no hold.
/api/v1/balances{
"object": "list",
"data": [
{
"object": "balance",
"network": "btc",
"asset": "BTC",
"amount": "0.0213",
"on_hold": "0",
"on_hold_until": null,
"fee_free_liquidity": "0",
"amount_usd": "1490.12"
},
{
"object": "balance",
"network": "tron",
"asset": "USDT",
"amount": "1452.31",
"on_hold": "0",
"on_hold_until": null,
"fee_free_liquidity": "200",
"amount_usd": "1452.31"
}
],
"has_more": false
}Top up liquidity
Your balance is your own hot wallet: payouts and refunds are paid from it, per coin and network. To pay out more than you've taken in, send coins to your top-up addresses. They're credited to your balance like any deposit, but with no fee, and fire topup.confirmed. That money is tracked as fee-free liquidity: payouts spend fee-paid money first, and if top-up money itself leaves the platform, 0.5% of that part is charged then. Every coin pays the fee once.
Top-up addresses take every coin the platform runs, whatever your store accepts for customers. Each mode has its own set; test top-ups can only be simulated. The same addresses are on the dashboard under Balance & payouts.
/api/v1/topupcurl https://z5.gg/api/v1/topup \ -H "Authorization: Bearer $ZEROFIVE_KEY"
{
"object": "topup",
"livemode": true,
"fee": "0",
"addresses": [
{
"network": "tron",
"name": "Tron",
"assets": ["TRX", "USDT"],
"address": "TXk3…",
"uri": "TXk3…",
"qr_url": "https://z5.gg/d/…/qr/tron"
},
…
]
}Payouts
Sends money from your balance to anyone: a user cashing out, a reward, a supplier. The amount leaves your balance the moment you call, and the full amount arrives. Payouts are free; we pay the gas.
- Liquidity. You can only pay out what your balance holds of that coin on that network. Otherwise you get a 400
invalid_requestsaying how much you have; top up first. - Bridged versions. USDC on Polygon and USDT on Optimism each also exist as an older bridged token (USDC.e, bridged USDT). Payouts send the standard one; only when the platform holds the amount in the bridged one instead does it go out as that, and
bridged_tokennames it (otherwise it's null). Same value, but some exchanges only credit the standard token, so check before paying out to an exchange on those two networks. - Idempotent. Pass your own id as
external_id. Sending the sameexternal_idagain returns the first payout instead of paying twice, so retrying after a timeout is safe. - Your rules. Every payout is checked against your payout rules: it goes out within a minute, waits in your review queue (status
review, webhookpayout.pending_review), or is refused with a 400. - Platform limit. Live payouts above the platform's automatic limit, per payout or per store over 24 hours, wait for the platform's OK (status
pending). Refunds and withdrawals count towards the same limit.
Refused with a 400 before anything is reserved:
- an address in the wrong format or checksum, a token's own contract, or an address that belongs to this platform;
- below the network's minimum (see fees), or more than the balance holds;
- in live mode, an address on a sanctions list, frozen by a stablecoin issuer, or banned (see screening);
- in live mode, a Solana token to a wallet that has never held that token (ask the user to receive any amount of it once, or use another network).
Statuses
reviewstatuspendingstatusapprovedstatusprocessingstatussentstatusfailedstatusrejectedstatus/api/v1/payoutsParameters
networkstringrequiredassetstringrequiredamountstringrequiredmax for the whole balance.addressstringrequiredcustomerstringexternal_idstringmetadataobject/api/v1/payoutsPayouts and refunds, newest first. Filter with customer, external_id, status or kind (payout, refund). Payouts have ids starting po_, refunds ref_.
/api/v1/payouts/:idcurl https://z5.gg/api/v1/payouts \ -H "Authorization: Bearer $ZEROFIVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "network": "tron", "asset": "USDT", "amount": "120", "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7", "customer": "cus_Qm4p…", "external_id": "wd_99812" }'
{
"id": "po_8Hn2…",
"object": "payout",
"livemode": true,
"status": "sent",
"customer": "cus_Qm4p…",
"invoice": null,
"external_id": "wd_99812",
"metadata": { "user": "812" },
"network": "tron",
"asset": "USDT",
"amount": "120",
"fee": "0",
"amount_usd": "120.00",
"address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
"bridged_token": null,
"tx_hash": "c81e…",
"explorer_url": "https://tronscan.org/#/transaction/c81e…",
"error": null,
"review_reasons": [],
"reviewed_at": null,
"created_at": "2026-10-20T12:00:00.000Z",
"processed_at": "2026-10-20T12:00:21.000Z"
}Payout rules
Your own rules for payouts to your users, set in Dashboard → Settings → Payout rules or over the API. They apply to POST /payouts in test and live mode. Your own withdrawals and invoice refunds skip them.
Each payout is checked in this order:
- Refused (400, nothing is created or reserved): blocked address, below the smallest or above the largest payout, a network you turned off, no customer when you require one, or an address that belongs to this platform.
- Held for review (status
review, the amount is reserved): above the instant limit or a per-coin limit, over a per-user or store-wide limit, a user's first payout or a new address (if turned on), within the deposit cooldown, an address not on your allowed list, or the emergency hold. - Sent: everything else, usually within a minute.
- Platform limit: after your approval, very large live payouts also wait for the platform admin (status
pending).
Limits count every payout that wasn't rejected, over a rolling 24 hours (or hour). A "user" is the customer; without one, the address. Checks run in the same transaction that reserves the money, so parallel requests can't slip past a limit together.
Rules
enabledbooleanholdAllbooleanautoApproveMaxUsdnumber | nullminPayoutUsd / maxPayoutUsdnumber | nullperUserDailyUsd / perUserDailyCountnumber | nullstoreDailyUsdnumber | nullstoreHourlyCountnumber | nullreviewFirstPayoutbooleanreviewNewAddressbooleandepositCooldownMinutesnumber | nullrequireCustomerbooleanblockedAddressesstring[]allowedAddressesstring[]assetMaxUsdobject{"BTC": 5000}; above waits for review.networksstring[]/api/v1/payouts/:id/approveSends a payout in review. Returns the payout: approved, or pending if it also needs the platform's OK. A payout in any other status gets a 400.
/api/v1/payouts/:id/rejectParameters
reasonstring/api/v1/payout-rules/api/v1/payout-rulesBoth return { "object": "payout_rules", ...rules }. Send only the rules you want to change; the rest stay as they are. Changing rules needs the Payout rules permission on a live key created by the store owner: a test key gets a 400 and a team member's key a 403, so a leaked key can't loosen live payouts. The rules apply to test and live payouts alike.
# approve a payout your rules held back curl -X POST https://z5.gg/api/v1/payouts/po_8Hn2…/approve \ -H "Authorization: Bearer $ZEROFIVE_KEY" # or refuse it; the amount returns to your balance curl -X POST https://z5.gg/api/v1/payouts/po_8Hn2…/reject \ -H "Authorization: Bearer $ZEROFIVE_KEY" \ -H "Content-Type: application/json" \ -d '{"reason": "Failed KYC"}'
curl https://z5.gg/api/v1/payout-rules \ -H "Authorization: Bearer $ZEROFIVE_LIVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "autoApproveMaxUsd": 500, "perUserDailyUsd": 2000, "reviewFirstPayout": true, "depositCooldownMinutes": 30, "blockedAddresses": ["TXyz…"] }'
{
"id": "po_8Hn2…",
"object": "payout",
"status": "review",
"amount": "900",
"asset": "USDT",
"customer": "cus_Qm4p…",
"external_id": "wd_99812",
"review_reasons": [
"Above the instant limit of $500.00",
"First payout for this user"
]
}Refunds
Refunds a paid (or partly paid) invoice from your balance, in the coin and network the customer paid with (the first one, if they paid in more than one). Leave out amount to refund everything they paid in that coin, less earlier refunds; pass it for a partial refund. Ask the customer for their address: the one they paid from may belong to an exchange. Needs the Payouts and refunds permission.
A refund is a payout with "object": "refund", an id starting ref_ and invoice set. It skips your payout rules, but the network minimum, address checks, screening and the platform limit apply as for payouts. It fires payout.* events, then invoice.refunded once sent, and emails the customer. The 0.5% fee on the original payment isn't returned. Once refunded, the invoice can't be paid again.
Overpayments. When a customer pays an invoice too much, the paid page offers them the extra back: they enter their address and it's refunded in the coin they paid with, less a fee you keep (1%, at least $1, by default). Set it to go out on its own, wait for your review, or not be offered at all under Settings → Payout rules. It's an ordinary refund with external_id overpay_inv_…, so the same webhooks fire. One request per invoice, within 30 days, and only when everything was paid in the quoted coin. Only the payer can ask: the browser they paid with, or the link in their receipt email (so pass customer_email when you can). Anyone else who opens the invoice just sees that there was an extra.
/api/v1/invoices/:id/refundParameters
addressstringrequiredamountstringreasonstringexternal_idstringcurl https://z5.gg/api/v1/invoices/inv_3fTq…/refund \ -H "Authorization: Bearer $ZEROFIVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "address": "TQ5vYp…", "reason": "Order canceled" }'
Withdrawals
Sends part or all of a balance to your own wallet. The amount is taken from your balance straight away and the full amount arrives: withdrawals are free. Needs a key with the Withdrawals permission.
In live mode the address must be one of your saved payout addresses (Balance & payouts), saved at least 24 hours earlier, and the account owners get an email for every withdrawal. Adding a payout address also emails them. Your payout rules don't apply to withdrawals.
Withdrawals under the platform's automatic limit (per withdrawal and per store over 24 hours) go out by themselves, usually within a couple of minutes. Larger ones wait for a quick manual review. Statuses: Pending (in review) → Approved → Processing → Sent, or Rejected with the amount returned to your balance. Events are withdrawal.*, not payout.*.
In the dashboard, live withdrawals, refunds and payouts can only be made by the owner or a team member the owner allowed to move money, and each one is confirmed with a code from an authenticator app or email. Over the API, the key's permissions decide, so give the Withdrawals permission only to keys that need it.
/api/v1/withdrawalsParameters
networkstringrequiredassetstringrequiredamountstringrequiredmax for the whole balance.addressstringrequiredexternal_idstring/api/v1/withdrawalsYour withdrawals (ids starting wd_), newest first; paginated.
/api/v1/withdrawals/:idcurl https://z5.gg/api/v1/withdrawals \ -H "Authorization: Bearer $ZEROFIVE_KEY" \ -H "Content-Type: application/json" \ -d '{ "network": "tron", "asset": "USDT", "amount": "max", "address": "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7" }'
Fees
0.5% of every payment, taken in the same coin when the payment is credited. A 49 USDT payment adds 48.755 USDT to your balance. That's the only fee.
- Withdrawals, payouts and refunds: free. We pay the gas and the full amount arrives.
- Top-ups to your own balance: free to add. Every coin pays the 0.5% once: payments on the way in, top-up money only if it leaves the platform. Payouts and withdrawals use fee-paid money first, so top-ups that just cover your users' payouts while deposits come in cost nothing. When top-up money itself goes out, 0.5% of that part is charged (shown as
feeon the payout, and asfee_free_liquidityonGET /balances). - No monthly fee, no setup fee, nothing in test mode.
To keep gas from costing more than a transfer moves, each network has a minimum for payouts, refunds and withdrawals, in dollars converted to the coin at the current price. GET /v1/networks returns the current value as min_withdrawal_usd:
| Network | Fee | Minimum payout |
|---|---|---|
| free | $25.00 | |
| free | $1.00 | |
| free | $1.00 | |
| free | $1.00 | |
| free | $1.00 | |
| free | $1.00 | |
| free | $1.00 | |
| free | $1.00 | |
| free | $1.00 | |
| free | $1.00 | |
| free | $5.00 | |
| free | $20.00 | |
| free | $2.00 | |
| free | $1.00 | |
| free | $1.00 | |
| free | $1.00 | |
| free | $1.00 |
Reports & exports
Balance transactions are every movement of your balance, one per event: payment, topup, withdrawal, payout, refund, withdrawal_reversal (a transfer that didn't go out, back in the balance) and adjustment. amount is what moved (negative going out), fee what you paid for it, net the change to your balance. USD values are at the time. available_at is when a payment could be sent on, later than created_at while a new store's payments are held. Filter with type, network, asset and the date range.
Exports are files of your records for a date range: balance_transactions, invoices, payments, payouts (withdrawals, payouts and refunds, with a kind column), customers or subscriptions, as csv (flat columns, UTF-8, opens in Excel) or json (the full API objects). from is inclusive, to exclusive. They're made in the background, usually within seconds; poll GET /v1/exports/:id and download with the same key. Files are kept for 7 days. Any key can make them, read-only ones included.
For accounting tools. balance_transactions also comes ready to import: xero (a bank statement in USD, fees on their own lines), quickbooks (QuickBooks Online bank upload: Date, Description, Amount in USD), koinly (Koinly's universal CSV in coin amounts, payments tagged other income) and cointracking (CoinTracking's CSV import: payments as Income, fees as Other Fee, top-ups and withdrawals as Deposit and Withdrawal, payouts and refunds as Spend). Dates are UTC. A movement with a fee can be two lines, so rows counts lines.
Receipts. A paid invoice has receipt_url and receipt_pdf_url; GET /v1/invoices/:id/pdf returns the same PDF with your key.
Monthly statements. Opening and closing balance per coin, everything in and out and the fees, for each calendar month (UTC), as a page, PDF and CSV under Reports in the dashboard. On the 1st, last month's live statement is emailed to everyone with Monthly statement on under Settings → Emails, including addresses like billing@ that don't have a login.
curl -g "https://z5.gg/api/v1/balance_transactions?created[gte]=2026-10-01&created[lt]=2026-11-01&limit=100" \ -H "Authorization: Bearer $ZEROFIVE_KEY"
{
"id": "led_BlBzXmefGjd1rPxh7JCP",
"object": "balance_transaction",
"livemode": true,
"type": "payment",
"network": "tron",
"asset": "USDT",
"amount": "49",
"fee": "0.245",
"net": "48.755",
"amount_usd": "49.00",
"fee_usd": "0.25",
"net_usd": "48.75",
"source": "pay_pGP6WpQD9Om0abB6ggyL",
"invoice": "inv_w6vTrRsgxmCazaAF0PCH",
"customer": null,
"external_id": null,
"tx_hash": "8f3c…e1a0",
"explorer_url": "https://tronscan.org/#/transaction/8f3c…e1a0",
"description": "Invoice inv_w6vTrRsgxmCazaAF0PCH",
"available_at": "2026-10-22T20:58:26.222Z",
"created_at": "2026-10-22T20:58:26.222Z"
}curl -X POST https://z5.gg/api/v1/exports \ -H "Authorization: Bearer $ZEROFIVE_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "invoices", "format": "csv", "from": "2026-09-01", "to": "2026-10-01"}' # poll until "status": "ready", then curl -o invoices.csv https://z5.gg/api/v1/exports/exp_.../download \ -H "Authorization: Bearer $ZEROFIVE_KEY"
Guides
Casino & wallet setup
For anything where users hold a balance with you and come and go: casinos, sportsbooks, wallets, games, exchanges.
- One customer per user.
POST /customerswithexternal_idset to your user id. It returns the existing customer if there is one, so call it whenever the user opens the deposit screen. - Show their address.
GET /customers/:id/addresses/:network. It never changes, so users can save it. Or link todeposit_page_url. - Credit on
deposit.confirmed. The payment'snet_amountis what reached your balance. Use the paymentidso a resent webhook can't credit twice. Never credit ondeposit.detected, and leavepayment.helddeposits alone. - Pay out with
POST /payouts. Pass the user's customer id and your withdrawal id asexternal_id. Mark it done onpayout.sent; put the money back onpayout.rejected. If your payout rules hold it,payout.pending_reviewfires: show the user "under review" until you approve or reject it. - Keep liquidity. Deposits fill your balance; if users withdraw more than they put in (winners, a different coin than they deposited), top up. Watch
GET /balances.
const zf = (path, body) => fetch("https://z5.gg/api/v1" + path, { method: body ? "POST" : "GET", headers: { Authorization: `Bearer ${process.env.ZEROFIVE_KEY}`, "Content-Type": "application/json", }, body: body && JSON.stringify(body), }).then((r) => r.json()); // 1. a user opens "Deposit": one customer per user, safe to call every time const customer = await zf("/customers", { external_id: user.id, email: user.email }); const { address } = await zf(`/customers/${customer.id}/addresses/tron`); showDepositAddress(address); // 2. webhook: credit the user when the deposit confirms if (event.type === "deposit.confirmed") { const p = event.data; // payment object await creditUser(p.customer, p.net_amount, p.asset, { once: p.id }); } // deposit.detected → show "pending"; payment.held → don't credit, it's under review // 3. the user withdraws: pay them from your balance await zf("/payouts", { network: "tron", asset: "USDT", amount: "120", address: user.withdrawAddress, customer: customer.id, external_id: withdrawal.id, // retry-safe }); // 4. webhooks: payout.pending_review → tell the user "under review" // payout.sent → mark the withdrawal done // payout.rejected → put the money back on the user
Reference
Webhooks
Set an endpoint URL in Dashboard → Developers and we POST every event for your store to it as JSON: {id, type, livemode, created, data}, where created is a Unix timestamp in seconds. Test and live mode each have their own URL and their own signing secret: set the live one with the dashboard in live mode. Test events are only ever signed with the test secret, so keep the live secret out of dev and staging.
Each request is signed with the secret of its mode (whsec_… live, whsec_test_… test, shown in Developers). The ZeroFive-Signature header looks like t=1792491271,v1=5257a8…:
- Split the header on
,and each part on=to gett(Unix seconds) andv1. - Compute the hex HMAC-SHA256 of
`${t}.${rawBody}`with your secret. Use the raw body: re-serialised JSON won't match. - Compare it with
v1in constant time, and reject timestamps more than a few minutes old so a captured request can't be replayed.
Answer with any 2xx within 10 seconds; redirects aren't followed. Anything else, or a timeout, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours: eight tries over almost two days. Every delivery is listed in the dashboard with its response and can be resent. Events can arrive more than once and out of order, so make your handler idempotent on id and check the object's current status. Endpoints must be public URLs without a user name or password in them, and https in live mode (http is accepted for test mode only): private and local addresses are refused.
data is the object, or several of them, as described per event below. Objects in events have the same shape as in the API, except invoice.refunded, where invoice is just the id.
Event types
invoice.createdinvoiceinvoice.payment_detectedinvoiceinvoice.paidinvoiceinvoice.partially_paidinvoiceinvoice.expiredinvoiceinvoice.canceledinvoiceinvoice.refundedrefunddeposit.detectedpaymentdeposit.confirmedpaymenttopup.confirmedpaymentpayment.heldpaymentpayment.declinedpaymentpayment.droppedpaymentpayment.needs_attentionpaymentpayout.createdpayoutpayout.pending_reviewpayoutpayout.sentpayoutpayout.failedpayoutpayout.rejectedpayoutwithdrawal.createdwithdrawalwithdrawal.sentwithdrawalwithdrawal.failedwithdrawalwithdrawal.rejectedwithdrawalsubscription.createdsubscriptionsubscription.activatedsubscriptionsubscription.renewedsubscriptionsubscription.payment_duesubscriptionsubscription.past_duesubscriptionsubscription.updatedsubscriptionsubscription.canceledsubscriptiontip.receivedtip{
"id": "evt_Vb8n…",
"type": "invoice.paid",
"livemode": true,
"created": 1792491271,
"data": {
"invoice": { "id": "inv_3fTq…", "status": "paid", "order_id": "A-1042", … },
"payment": { "id": "pay_7Yc2…", "amount": "49", "asset": "USDT", … }
}
}import crypto from "node:crypto"; // rawBody: the request body exactly as received, before any JSON parsing export function verify(rawBody, header, secret, toleranceSeconds = 300) { const parts = Object.fromEntries( String(header ?? "").split(",").map((p) => p.trim().split("=")), ); const t = Number(parts.t); if (!t || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // replayed const expected = crypto .createHmac("sha256", secret) .update(`${t}.${rawBody}`) .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); return a.length === b.length && crypto.timingSafeEqual(a, b); }
app.post("/zerofive", express.raw({ type: "application/json" }), (req, res) => { const raw = req.body.toString("utf8"); if (!verify(raw, req.get("ZeroFive-Signature"), process.env.ZEROFIVE_WHSEC)) { return res.sendStatus(400); } const event = JSON.parse(raw); if (event.type === "invoice.paid") fulfil(event.data.invoice.order_id); res.sendStatus(200); });
export async function POST(req) { const raw = await req.text(); const sig = req.headers.get("zerofive-signature"); if (!verify(raw, sig, process.env.ZEROFIVE_WHSEC)) { return new Response("bad signature", { status: 400 }); } const event = JSON.parse(raw); // handle it, idempotently on event.id, then answer fast return new Response(null, { status: 200 }); }
Screening & holds
Live payments are checked before they're credited, so stolen or sanctioned money doesn't end up in your balance (and your own coins don't get frozen with it). Test mode skips all of this.
- What's checked. The sending wallet, against the OFAC sanctions list (refreshed daily), the freeze lists stablecoin issuers keep (Tether, Circle, Paxos and others, including frozen token accounts on Solana and locked USDT wallets on TON), and our own list of wallets that already sent flagged money. Then the payer: the customer's
name, if you gave one, against the UN, EU and OFAC sanctions lists, and the invoice'scustomer_country. - Where we don't serve. Checkout pages aren't served to visitors in sanctioned countries and regions (including Crimea and the Donetsk and Luhansk regions), and an invoice with one of them as its
customer_countryis refused with a400. - Your store. Store and team member names are screened against the same lists when they change and whenever the lists are updated. A match is reviewed by the platform, and the store can be suspended meanwhile.
- A hit. The payment becomes
heldandpayment.heldfires. It isn't credited, the invoice stays unpaid, and noinvoice.paidordeposit.confirmedfollows. Don't ship or credit anything. The platform reviews it: if it's cleared, it's credited as usual and the normal events fire; if not, it becomesdeclined(payment.declined), is never credited, and an invoice it was the only payment on is canceled. - Large payments. Live payments of $1,000 or more wait about 60 minutes after their confirmations and are screened again before they're credited, since issuers often freeze stolen funds hours after the theft. They stay
confirmingmeanwhile; no extra event fires. - Money going out. Live payouts, refunds and withdrawals to a sanctioned, frozen or banned address are refused with a
400.
invoice.paid or deposit.confirmed, never on the detected events.Networks & coins
The keys below are what network means everywhere in the API, and asset is the coin's symbol in capitals. Tokens are matched by contract address, never by name, so look-alike tokens are ignored. Dollar stablecoins count 1:1 with the dollar; EURC is priced at the euro rate, so its amount_usd moves with EUR/USD.
Read GET /v1/networks instead of hard-coding this list. The platform can switch single networks or coins off (and new ones on), and the endpoint always returns what can be paid with right now. Your store and each API key can narrow that further. Asking for a coin that's off gets a 400.
Payments under $1,000 need fewer confirmations than larger ones, so most customers are done in seconds. Each payment's confirmations_required says which applied. min_withdrawal_usd is the smallest payout, refund or withdrawal on that network; withdrawal_fee_usd is always 0.
# public, no key needed curl https://z5.gg/api/v1/networks
{
"object": "list",
"has_more": false,
"data": [
{
"network": "tron",
"name": "Tron",
"assets": [
{ "symbol": "TRX", "contract": null, "decimals": 6 },
{
"symbol": "USDT",
"contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
"decimals": 6
}
],
"confirmations_required": 19,
"eta": "about 1 minute",
"confirmations_required_small": 6,
"eta_small": "about 20 seconds",
"small_payment_limit_usd": 1000,
"withdrawal_fee_usd": 0,
"min_withdrawal_usd": 5
}
]
}Networks
| Network | Key | Coins | Under $1,000 | $1,000 and up |
|---|---|---|---|---|
| eth | 3about 40 seconds | 12about 3 minutes | ||
| base | 5about 10 seconds | 30about 1 minute | ||
| arbitrum | 20about 5 seconds | 60under a minute | ||
| optimism | 5about 10 seconds | 30about 1 minute | ||
| polygon | 16about 30 seconds | 40about 1–2 minutes | ||
| bsc | 5a few seconds | 15under a minute | ||
| avalanche | 6a few seconds | 12about 15 seconds | ||
| plasma | 4a few seconds | 10about 10 seconds | ||
| arc | 1a few seconds | 2a few seconds | ||
| tempo | 1a few seconds | 2a few seconds | ||
| tron | 6about 20 seconds | 19about 1 minute | ||
| btc | 1about 10 minutes | 2about 20 minutes | ||
| ltc | 1about 3 minutes | 3about 8 minutes | ||
| doge | 3about 3 minutes | 10about 10 minutes | ||
| bch | 1about 10 minutes | 6about 1 hour | ||
| sol | finalizedabout 15 seconds | finalizedunder a minute | ||
| ton | finalizeda few seconds | finalizeda few seconds |
Ethereum, Base, Arbitrum, Optimism, Polygon, BNB Chain, Avalanche, Plasma, Arc and Tempo are EVM networks and share one address per customer. Solana and TON count once a transaction is final rather than after a number of blocks.
Coins and contracts
Amounts in the API are decimal strings in the coin itself ("49.5", not base units). Where a symbol has two contracts on one network (native and bridged USDC on Polygon, two USDT contracts on Optimism), both are credited as that symbol; /v1/networks shows the first.
| Network | Coin | Decimals | Contract, mint or jetton master |
|---|---|---|---|
| Ethereum | 18 | native | |
| 6 | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 | ||
| 6 | 0xdAC17F958D2ee523a2206206994597C13D831ec7 | ||
| 18 | 0x6B175474E89094C44Da98b954EedeAC495271d0F | ||
| 6 | 0x6c3ea9036406852006290770BEdFcAbA0e23A0e8 | ||
| USD1 | 18 | 0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d | |
| 6 | 0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33cpriced in euros | ||
| Base | 18 | native | |
| 6 | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 | ||
| 6 | 0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2 | ||
| 6 | 0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42priced in euros | ||
| Arbitrum | 18 | native | |
| 6 | 0xaf88d065e77c8cC2239327C5EDb3A432268e5831 | ||
| 6 | 0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9 | ||
| 6 | 0x46850aD61C2B7d64d08c9C754F45254596696984 | ||
| Optimism | 18 | native | |
| 6 | 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85 | ||
| 6 | 0x94b008aA00579c1307B0EF2c499aD98a8ce58e58 | ||
| 6 | 0x01bFF41798a0BcF287b996046Ca68b395DbC1071 | ||
| Polygon | 18 | native | |
| 6 | 0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359 | ||
| 6 | 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 | ||
| 6 | 0xc2132D05D31c914a87C6611C10748AEb04B58e8F | ||
| 18 | 0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063 | ||
| BNB Chain | 18 | native | |
| 18 | 0x55d398326f99059fF775485246999027B3197955 | ||
| 18 | 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d | ||
| USD1 | 18 | 0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d | |
| Avalanche | 18 | native | |
| 6 | 0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E | ||
| 6 | 0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7 | ||
| 6 | 0xC891EB4cbdEFf6e073e859e987815Ed1505c2ACDpriced in euros | ||
| Plasma | 18 | native | |
| 6 | 0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb | ||
| 6 | 0x2d661C89D812261039AF9764eceaAee884f5F67F | ||
| Arc | 18 | native (the gas coin) | |
| 6 | 0xbEf5f6d51CB62b58e6A8f77868681825C6fe21c1priced in euros | ||
| Tempo | 6 | 0x20C000000000000000000000b9537d11c60E8b50 | |
| 6 | 0x20C00000000000000000000014f22CA97301EB73 | ||
| Tron | 6 | native | |
| 6 | TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t | ||
| Bitcoin | 8 | native | |
| Litecoin | 8 | native | |
| Dogecoin | 8 | native | |
| Bitcoin Cash | 8 | native | |
| Solana | 9 | native | |
| 6 | EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v | ||
| 6 | Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB | ||
| USD1 | 6 | USD1ttGY1N17NEEHLmELoaybftRBUSErhqYiQzvEmuB | |
| 6 | HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtrpriced in euros | ||
| TON | 9 | native | |
| 6 | EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs |
Things to tell your customers
- Bitcoin Cash addresses are CashAddr (
bitcoincash:q…). Payout addresses in the legacy1…form are refused, because they look exactly like Bitcoin ones. - Solana: tokens sent through the Token-2022 program aren't detected. Token payouts only go to wallets that already hold that token.
- TON: every invoice and customer has its own address, so no memo or comment is needed.
- Bitcoin on-chain only, no Lightning. Litecoin without MWEB.
- Arc: USDC is the network's own gas coin, with 18 decimals. Tempo has no coin of its own.
- Each network's warning (in the table above, and in a customer address's
warning) is worth showing next to the address.
All endpoints
Every endpoint, relative to https://z5.gg/api, with the key permission it needs. "Read" works with any key.