Balances
Let Norelio keep the buyer's credit balance: enable it on the top-up product, read the balance, debit before each model call with an idempotency key, grant bonuses, read the history, balanceAfter in the webhook, test balances with the test key — and when to keep the ledger yourself instead.
Until now Norelio reported how many credits a buyer bought and left the ledger to you. With balances, Norelio keeps the buyer's credit balance for you: every paid top-up adds its credits to the buyer's balance, your server debits credits with one request before doing the work, grants bonuses and reads the history. Credits are not money: the balance never changes the partner's earnings, payouts, reports or VAT, and a balance is not a wallet the buyer can withdraw.
Two ways to keep credits
Two ways to keep credits, chosen per product:
- You keep the ledger
- You keep the credits yourself: on order.paid you add the credits from the webhook to your own database, on refund events you remove the share, and your code decides when the user may call the model. This is how top-ups worked before balances, and it stays the default. See Top-ups.
- Norelio keeps the balance
- Norelio keeps the balance: you enable it on the top-up product, every paid order adds its credits to the buyer's balance at Norelio, refunds remove their share, and your server asks Norelio to debit credits before each piece of work. The webhook still arrives — with the balance after the order in balanceAfter — and you still deduplicate it by eventId, but you no longer have to store a ledger.
Keep the ledger yourself when credits are already part of your product's data model, when you meter usage in your own database anyway, when you need offline or batch accounting, or when a model call must not wait for a network request to Norelio.
Let Norelio keep the balance when you want to go live without a ledger of your own, when several of your services share one balance, when your support team should grant and debit credits from the cabinet, or when you want one place that shows the buyer's balance and history.
Enable it on the product
In the cabinet catalog, open the top-up product and tick "Keep the balance in Norelio" in the top-up block; it is off by default and the partner can switch it on and off. From then on every paid order of this product credits the balance. Switching it off deletes nothing — balances and history stay, and only new purchases stop being credited. Orders paid while the balance was off are not credited later.
A balance belongs to the triple partner + clientReference + unit: your account reference for the buyer and the unit name from the product ("credits", "tokens", up to 24 characters, exactly as written on the product). Two products with the same unit and the balance enabled credit the same balance; two units are two balances. The unit of a paid order is its creditsUnit from the webhook.
Read the balance
Read the balance: the current balance and when it last moved. No balance yet answers balance: 0 with updatedAt: null — not an error, and reading creates nothing. The server API is for a partner whose application knows its own user: a payment is created from your server, with no button or panel on a page, and the user's state (subscriptions and purchases) is read by your own account reference. The key is the same as for the access check (API key tab in the cabinet), sent as Authorization: Bearer; the partner is identified by the key, there is no partner parameter. Rate limits are the same as for the access check: per your server's address and per key. Responses are JSON only and never cached. You can ask only about your own products and buyers: a foreign product or buyer and a non-existent one answer alike.
GET https://app.norelio.eu/api/v1/customers/balance?clientReference={reference}&unit={unit}
Authorization: Bearer {api key}curl -s "https://app.norelio.eu/api/v1/customers/balance?clientReference=acc_10294&unit=credits" \
-H "Authorization: Bearer $NORELIO_API_KEY"
{"clientReference":"acc_10294","unit":"credits","balance":250,"updatedAt":"2026-03-14T09:26:54.000Z"}| Field | Description |
|---|---|
clientReference | Your account reference for the buyer, as passed. |
unit | The unit name, as passed. |
balance | The current balance, an integer; 0 when there is no balance yet; negative only after a refund. |
updatedAt | When the balance last moved, ISO 8601, UTC; null when there is no balance yet. |
{
"clientReference": "acc_new_user",
"unit": "credits",
"balance": 0,
"updatedAt": null
}Debit
Debit: remove credits before the work. The request names the buyer, the unit, a positive integer amount, your idempotency key and an optional reason. The answer is the balance after the debit and the id of the movement.
POST https://app.norelio.eu/api/v1/customers/balance/debit
Authorization: Bearer {api key}
Content-Type: application/json| Field | Description |
|---|---|
clientReference | Required. Your account reference for the buyer — the same format as at checkout (up to 128 characters). |
unit | Required. The unit name from the top-up product, 1 to 24 characters. |
amount | Required. A positive integer number of credits, at most 9007199254740991. |
idempotencyKey | Required. A string of 1 to 128 characters unique to this operation; repeat it to retry safely. |
reason | Optional. Up to 500 characters; shown in the history and in the cabinet. |
{
"clientReference": "acc_10294",
"unit": "credits",
"amount": 3,
"idempotencyKey": "chat_7f3c2a9e-1b4d-4e8f-9a0c-5d6e7f8a9b0c",
"reason": "chat completion"
}curl -s -X POST "https://app.norelio.eu/api/v1/customers/balance/debit" \
-H "Authorization: Bearer $NORELIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"clientReference":"acc_10294","unit":"credits","amount":3,"idempotencyKey":"chat_7f3c2a9e-1b4d-4e8f-9a0c-5d6e7f8a9b0c","reason":"chat completion"}'
{"balance":247,"transactionId":"cm8g1d4k20009ij7l2m5n8p1q"}200 OK:
| Field | Description |
|---|---|
balance | The balance after this movement (or after the original one, on an idempotent repeat). |
transactionId | The id of the movement, the same as in the history. |
{
"balance": 247,
"transactionId": "cm8g1d4k20009ij7l2m5n8p1q"
}A debit larger than the balance answers 409 insufficient_balance and moves nothing; the current balance is in the balance field of the error response, next to error, so you can tell the user how much is left. A balance never goes negative by a debit — only a refund can take it below zero (the buyer had already spent credits the refund takes back); a negative balance is reported as a negative integer, and new purchases and grants raise it as usual.
{
"error": {
"code": "insufficient_balance",
"message": "The balance is lower than the amount to debit. The current balance is in the balance field of this response."
},
"balance": 2
}Concurrent debits of one balance are safe: each debit is one conditional update in one database transaction, so fifty parallel debits of 1 from a balance of 30 give exactly thirty successes, twenty 409 answers and a balance of 0 — never a negative balance, never a lost movement.
Idempotency
idempotencyKey is required on debit and grant: a string of up to 128 characters unique to this operation — the id of the user's request, for example. A repeated request with the same key and the same body answers exactly as the first one did, without a second movement; the same key with a different body answers 422 idempotency_conflict and moves nothing. Keys are per partner and never expire.
{
"error": {
"code": "idempotency_conflict",
"message": "A request with this idempotencyKey was already processed with a different body. Use a new key for a new operation."
}
}Grant
Grant: add credits — a welcome bonus, a compensation, a migrated balance. Same body, same answer, same idempotency as the debit.
POST https://app.norelio.eu/api/v1/customers/balance/grant
Authorization: Bearer {api key}
Content-Type: application/json{
"clientReference": "acc_10294",
"unit": "credits",
"amount": 50,
"idempotencyKey": "welcome_bonus_acc_10294",
"reason": "welcome bonus"
}curl -s -X POST "https://app.norelio.eu/api/v1/customers/balance/grant" \
-H "Authorization: Bearer $NORELIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"clientReference":"acc_10294","unit":"credits","amount":50,"idempotencyKey":"welcome_bonus_acc_10294","reason":"welcome bonus"}'
{"balance":297,"transactionId":"cm8g1d4k2000ajk8m3n6p9q2r"}{
"balance": 297,
"transactionId": "cm8g1d4k2000ajk8m3n6p9q2r"
}History
History: the movements of one balance, newest first, 50 per page; nextCursor is the cursor of the next page, null on the last one. Every movement shows its kind, the signed amount, the balance after it, the order (for a purchase or a refund) or your reason.
GET https://app.norelio.eu/api/v1/customers/balance/transactions?clientReference={reference}&unit={unit}&cursor={nextCursor}
Authorization: Bearer {api key}curl -s "https://app.norelio.eu/api/v1/customers/balance/transactions?clientReference=acc_10294&unit=credits" \
-H "Authorization: Bearer $NORELIO_API_KEY"
{"clientReference":"acc_10294","unit":"credits","transactions":[{"id":"cm8g1d4k20009ij7l2m5n8p1q","kind":"debit","amount":-3,"balanceAfter":247,"orderId":null,"reason":"chat completion","createdAt":"2026-03-14T09:30:02.000Z"},{"id":"cm8f4m7r20004cd5e1v8u3t0t","kind":"order_credit","amount":100,"balanceAfter":250,"orderId":"ord_t0p4u9","reason":null,"createdAt":"2026-03-14T09:26:54.000Z"},{"id":"cm8e9c2j10002gh4i7k0l3m6n","kind":"grant","amount":150,"balanceAfter":150,"orderId":null,"reason":"migrated balance","createdAt":"2026-03-01T08:00:00.000Z"}],"nextCursor":null}| Field | Description |
|---|---|
id | The movement id. |
kind | What moved the balance: order_credit, refund_debit, debit or grant. |
amount | The signed number of credits: positive for a purchase or a grant, negative for a refund or a debit. |
balanceAfter | The balance right after this movement. |
orderId | The order id (the paymentId of its webhooks) for a purchase credit and a refund debit; null for your debits and grants. |
reason | Your reason from the request or from the cabinet; null when none was given. |
createdAt | When the movement was recorded, ISO 8601, UTC. |
| Kind | What it is |
|---|---|
order_credit | the credits of a paid top-up order, added once per order |
refund_debit | the share of a refund, removed once per refund: floor(credits × refundedAmount ÷ amount), the order's remaining credits on a full refund |
debit | a debit by your server (API) or by the partner's team (cabinet) |
grant | a grant by your server (API) or by the partner's team (cabinet) |
{
"clientReference": "acc_10294",
"unit": "credits",
"transactions": [
{
"id": "cm8g1d4k20009ij7l2m5n8p1q",
"kind": "debit",
"amount": -3,
"balanceAfter": 247,
"orderId": null,
"reason": "chat completion",
"createdAt": "2026-03-14T09:30:02.000Z"
},
{
"id": "cm8f4m7r20004cd5e1v8u3t0t",
"kind": "order_credit",
"amount": 100,
"balanceAfter": 250,
"orderId": "ord_t0p4u9",
"reason": null,
"createdAt": "2026-03-14T09:26:54.000Z"
},
{
"id": "cm8e9c2j10002gh4i7k0l3m6n",
"kind": "grant",
"amount": 150,
"balanceAfter": 150,
"orderId": null,
"reason": "migrated balance",
"createdAt": "2026-03-01T08:00:00.000Z"
}
],
"nextCursor": null
}In the customer state and in the webhook
The customer state carries the buyer's balances too: balances, one entry per unit with its balance. Asked by clientReference it lists that reference's balances; asked by email, the balances of the account references found on the buyer's purchases. See Customer state.
{
"balances": [
{
"unit": "credits",
"balance": 250
}
]
}On a top-up product with the balance enabled, order.paid carries balanceAfter — the balance after the order's credits were added — and the refund events carry the balance after the refund's share was removed. On every other order and event balanceAfter is null. The field was added without a version change. You still deduplicate on eventId; you just no longer have to add the credits yourself. The fields are described in Webhooks.
{
"version": 1,
"event": "order.paid",
"test": false,
"productId": "prod_7t0p9u",
"customerEmail": "buyer@example.com",
"paymentId": "ord_t0p4u9",
"amount": 1000,
"currency": "EUR",
"taxAmount": 0,
"occurredAt": "2026-03-14T09:26:53.000Z",
"accessValidUntil": null,
"clientReference": "acc_10294",
"credits": 100,
"creditsUnit": "credits",
"refundedAmount": null,
"eventId": "cm8f4m7r20003cd5e1v8u3t0s",
"balanceAfter": 250
}Refunds
Refunds are handled for you: on every refund of a credited order Norelio removes floor(credits × refundedAmount ÷ amount) — the same share the Top-ups section asks you to remove yourself — and on a full refund the order's remaining credits, once per refund. The balance can go negative when the buyer has already spent the credits; nothing else ever takes it below zero.
{
"version": 1,
"event": "order.refunded_partial",
"test": false,
"productId": "prod_7t0p9u",
"customerEmail": "buyer@example.com",
"paymentId": "ord_t0p4u9",
"amount": 1000,
"currency": "EUR",
"taxAmount": 0,
"occurredAt": "2026-03-16T11:02:10.000Z",
"accessValidUntil": null,
"clientReference": "acc_10294",
"credits": 100,
"creditsUnit": "credits",
"refundedAmount": 400,
"eventId": "cm8h2p5n80007ef3g6w1x9y2z",
"balanceAfter": 210
}Debit before each model call
A debit before each model call, in Next.js, Node.js and Python: the user's request id is the idempotency key, 409 is a normal answer, and a model failure after the debit grants the credits back under the same request id.
// Server only: never import this file into code that runs in the browser.
const NORELIO_API = "https://app.norelio.eu";
const UNIT = "credits";
const CREDITS_PER_ANSWER = 1;
type DebitResult = { ok: true; balance: number } | { ok: false; balance: number };
async function balanceCall(path: string, body: Record<string, unknown>): Promise<Response> {
// The key comes from the server environment (NORELIO_API_KEY).
return fetch(`${NORELIO_API}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.NORELIO_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
// Debit BEFORE the work: the balance is the gate, and a failed debit means no model call.
export async function debitCredits(clientReference: string, unit: string, amount: number, requestId: string): Promise<DebitResult> {
// The idempotency key is the user's request id: a network retry with the same key answers the same way and debits once.
const response = await balanceCall("/api/v1/customers/balance/debit", {
clientReference,
unit,
amount,
idempotencyKey: `${requestId}:debit`,
reason: "model call",
});
const body = await response.json();
// Not enough credits is a normal answer, not an exception: 409 insufficient_balance with the current balance next to error.
if (response.status === 409 && body.error?.code === "insufficient_balance") {
return { ok: false, balance: body.balance };
}
if (!response.ok) throw new Error(`Norelio ${response.status} ${body.error?.code}: ${body.error?.message}`);
return { ok: true, balance: body.balance };
}
export async function grantCredits(clientReference: string, unit: string, amount: number, requestId: string, reason: string) {
const response = await balanceCall("/api/v1/customers/balance/grant", {
clientReference,
unit,
amount,
idempotencyKey: `${requestId}:grant`,
reason,
});
const body = await response.json();
if (!response.ok) throw new Error(`Norelio ${response.status} ${body.error?.code}: ${body.error?.message}`);
return body.balance;
}
export async function answerWithModel(userId: string, requestId: string, prompt: string) {
const debit = await debitCredits(userId, UNIT, CREDITS_PER_ANSWER, requestId);
if (!debit.ok) {
return { error: "insufficient_balance", balance: debit.balance };
}
try {
const answer = await callYourModel(prompt);
return { answer, balance: debit.balance };
} catch (error) {
// The work failed after the debit: give the credits back under the same request id, so a retry of this handler cannot grant twice.
await grantCredits(userId, UNIT, CREDITS_PER_ANSWER, requestId, "model call failed");
throw error;
}
}
async function callYourModel(prompt: string): Promise<string> {
// your model provider
return `echo: ${prompt}`;
}In the cabinet
The cabinet tab API and webhooks → Balances shows the balances: search by account reference, the balance per unit, the history, and two buttons — grant and debit with a required reason — recorded with the email of the team member who pressed them. The platform's staff can see a partner's balances but never change them.
Test mode
With the test key the same four endpoints read and change test balances only: a test top-up paid on the test page credits a test balance (when the product has the balance enabled), test refunds remove their share, and the test customer state lists test balances. The live key never sees a test balance and the test key never sees a live one. Test balances are shown on the cabinet tab Test orders and are deleted 30 days after their last movement. See Test mode.
Errors
An error from any API endpoint is JSON of one shape: an error object with code (one of the codes below) and message (a short English sentence). A 429 or 503 response carries a Retry-After header. The access check keeps its own, older error shape.
| Status | Code | When |
|---|---|---|
429 | rate_limited | too many requests in a row, from your address or with your key |
503 | rate_limiter_busy | the request could not be counted against the rate limits, so nothing was done; send the same request again after the number of seconds in the Retry-After header |
401 | invalid_api_key | the key is missing or does not match |
400 | invalid_request | the body or the query is malformed; message says what exactly |
403 | direct_checkout_not_enabled | creating payments from your server is not yet enabled for your account: the platform first reviews your purchase terms (the same review as for direct checkout) |
404 | product_not_found | no product with this id belongs to your account, or it cannot be bought right now |
502 | checkout_failed | the payment provider could not start the payment; retry later |
404 | not_found | no such endpoint |
405 | method_not_allowed | the endpoint does not accept that method |
403 | test_key_required | the endpoint works only with the test API key (it starts with nrl_test_), and the request came with the live key |
404 | test_order_not_found | no test order with this id belongs to your account, or it has already been deleted: test orders live for 30 days |
409 | test_action_not_allowed | the action is not possible in the test order's current state, such as a refund of an order not yet paid or a renewal of a canceled subscription; message names the reason |
409 | insufficient_balance | the balance is lower than the amount to debit; nothing was debited, and the response carries the current balance in the balance field next to error |
422 | idempotency_conflict | a request with this idempotencyKey was already processed with a different body; use a new key for a new operation |
{
"error": {
"code": "direct_checkout_not_enabled",
"message": "Creating payments from your server is enabled after the platform has reviewed your purchase terms. Ask support from the cabinet's Messages section."
}
}