Appearance
Credits & top-ups — client integration
Everything a customer spends is tokens, held in one wallet. Plans add tokens every cycle, top-ups add tokens once, and every generation — text, image, video, audio, on any model — spends tokens at what that call actually cost us.
A top-up is built by the customer, like a telecom "make your own pack": they choose how many credits they want (and, where an admin has switched them on, things like images or videos) and the server prices it. The client sends only the selection. It never computes or sends a price or a token count.
The model in one paragraph
A token is a fixed slice of provider cost (admin-set; ৳18 per 1M tokens by default), and a credit is a bundle of tokens (10,000 by default). The admin defines the components a top-up can be built from, their ranges and prices, and rules that add bonuses or discounts (volume bonuses, weekend extras, a first top-up bonus). The server applies all of it and never lets promotions take profit below a floor. A generation reserves its estimated cost in tokens and is trued up to the real cost afterwards.
No package is required. A customer with tokens can generate on any available model. New accounts get the Free Trial once, automatically (see GET /v1/plans, features.signupDefault).
Endpoints
| Method | Path | Auth | Use it for |
|---|---|---|---|
| GET | /v1/topup/options | public | Build the top-up screen: components, ranges, unit prices, offers, channel rules |
| POST | /v1/topup/quote | optional | Price a selection as the customer changes it |
| POST | /v1/topup/orders | user | Buy: price again and open the checkout |
| GET | /v1/topup/orders/:id | user | Poll after the gateway redirect |
| GET | /v1/topup/orders | user | Top-up history |
| GET | /v1/credits/balance | user | Wallet balance and lifetime totals |
| GET | /v1/credits/transactions | user | Statement, newest first, cursor-paged |
| GET | /v1/credits/rates | public | Token price of every model — cost hints beside Generate |
| POST | /v1/credits/estimate | public | Tokens one generation will reserve |
All money is BDT poisha (22500 = ৳225). Token counts are integers.
1. The builder screen
bash
curl http://localhost:4040/v1/topup/optionsjson
{
"enabled": true,
"currency": "BDT",
"tokensPerCredit": 10000,
"minOrderCents": 2000,
"maxOrderCents": 1000000,
"roundToCents": 100,
"charsPer300Tokens": 1000,
"components": [
{
"key": "credits", "name": "Credits", "description": "Spend on any model — images, video, audio and text",
"kind": "CREDITS", "unit": "credit",
"minUnits": 50, "maxUnits": 20000, "step": 10, "defaultUnits": 500,
"tokensPerUnit": 10000,
"pricing": { "mode": "formula", "unitPriceCents": 45 }
}
],
"offers": [
{ "id": "…", "name": "Volume bonus: 500 credits", "description": "Buy 500 credits, get 50 more", "type": "BONUS",
"conditions": { "audience": "ALL", "minUnits": { "component": "credits", "units": 500 } },
"effect": { "type": "BONUS_UNITS", "component": "credits", "units": 50 }, "endsAt": null }
],
"channels": [
{ "slug": "sslcommerz", "name": "SSLCommerz", "enabled": true, "minAmountCents": null, "maxAmountCents": null, "amountsCents": [] },
{ "slug": "dcb", "name": "Carrier billing", "enabled": true, "minAmountCents": null, "maxAmountCents": null, "amountsCents": [9900, 49900] }
]
}- Hide the screen when
enabledisfalse. - One control per component — a slider or stepper from
minUnitstomaxUnitsinsteps, starting atdefaultUnits. 0 leaves a component out; any other value must be in range. Label withunit("500 credits", "20 images"). pricingis for display only ("৳0.45 a credit", or thetierstable). Always show the price from the quote: rounding, bonuses and discounts only exist there.offersare the rules running now, to advertise ("Buy 2,000 credits, get 300 more"). Whether one applies is decided by the quote.kind: "MODEL_USAGE"components ("images") grant the tokens that much usage costs. The tokens are spendable on anything — say so ("≈ 20 images' worth").
2. Pricing a selection
Call on every change (debounce ~250 ms). Send the bearer token if the customer is signed in: first-purchase and new-account offers depend on who they are.
bash
curl -X POST http://localhost:4040/v1/topup/quote -H 'Content-Type: application/json' \
-d '{ "selection": { "credits": 500 } }'json
{
"valid": true,
"issues": [],
"currency": "BDT",
"lines": [
{ "key": "credits", "name": "Credits", "kind": "CREDITS", "unit": "credit", "units": 500,
"tokensPerUnit": 10000, "tokens": 5000000, "unitPriceCents": 45, "priceCents": 22500, "tierMinUnits": null }
],
"subtotalCents": 22500,
"discountCents": 0,
"amountCents": 22500,
"tokens": 5000000,
"bonusTokens": 500000,
"totalTokens": 5500000,
"credits": 500,
"bonusCredits": 50,
"totalCredits": 550,
"applied": [
{ "id": "…", "name": "Volume bonus: 500 credits", "type": "BONUS",
"effect": { "type": "BONUS_UNITS", "component": "credits", "units": 50 }, "discountCents": 0, "bonusTokens": 500000 }
],
"offers": [
{ "id": "…", "name": "First top-up bonus", "type": "PROMOTION", "requirement": "first top-up only — sign in to check" }
],
"payableWith": ["sslcommerz", "bkash"],
"unavailableChannels": [{ "slug": "dcb", "reason": "only takes ৳99, ৳499" }],
"usage": { "items": [ { "displayName": "Qwen Image 3", "unit": "image", "units": 6 }, "…" ], "tokensSpent": 0, "tokensLeft": 0 }
}(The bonus shown is one of the volume bonus templates, switched on for the example. All rule templates ship switched off.)
- Pay:
amountCents. ShowsubtotalCentscrossed out whendiscountCents > 0. - Get:
totalCredits(andtotalTokens); showbonusCreditsas "+50 bonus". issues— show under the matching control (fieldis the component key, oramountfor order limits). An invalid selection still answers 200, withvalid: false, and cannot be bought.offers— nudges: "add 100 credits for +50 bonus", "pay with bKash for ৳10 off", "sign in". Rules that cannot apply for other reasons are not listed.payableWith— offer only these channels (intersect withGET /v1/payments/channelsfor logos and required fields). ShowunavailableChannels[].reasonbeside a greyed-out one. Carrier billing only takes registered amounts.usage— "enough for 6 images · 1 video · …", split across the cheapest model of each kind.
Send channel once the customer picks one: channel-only promotions then apply.
3. Buying
bash
curl -X POST http://localhost:4040/v1/topup/orders \
-H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{ "selection": { "credits": 500 }, "provider": "bkash", "expectedAmountCents": 22500 }'
# → { "orderId": "…", "paymentId": "…", "redirectUrl": "https://…",
# "amountCents": 22500, "tokens": 5000000, "bonusTokens": 500000, "totalTokens": 5500000 }The server prices the selection again from scratch and charges that. Send expectedAmountCents (the amount you showed): if it no longer matches — a promotion ended, a rule changed — the order is refused so nobody pays a price they did not see:
json
{ "error": { "code": "PRICE_CHANGED", "message": "the price is now ৳250 — please confirm", "details": { "amountCents": 25000, "totalTokens": 5000000 } } }HTTP 409. Show the new price and let them confirm (send the new amount).
Other refusals (400, message safe to show): an invalid selection (details.issues), a channel that cannot take the amount, top-ups switched off, a couponCode (promotions are automatic).
- Carrier billing (
dcb) —extra.msisdnandextra.paymentProvideras for a plan. A top-up is always one charge. - The tokens and bonus are fixed on the order when it is placed and granted exactly once when the gateway confirms.
4. After the redirect
The gateway sends the customer back to your page, but the webhook decides. Poll with the orderId:
bash
curl http://localhost:4040/v1/topup/orders/ord_… -H "Authorization: Bearer $TOK"json
{
"id": "…", "status": "SUCCEEDED",
"selection": { "credits": 500 }, "lines": [ "…" ], "appliedRules": [ "…" ],
"subtotalCents": 22500, "discountCents": 0, "amountCents": 22500, "currency": "BDT",
"tokens": 5000000, "bonusTokens": 500000, "totalTokens": 5500000, "channel": "bkash",
"payment": { "id": "…", "status": "SUCCEEDED", "provider": "BKASH", "paidAt": "2026-09-15T10:03:40.000Z" },
"tokensGranted": 5500000,
"createdAt": "2026-09-15T10:02:11.000Z"
}status | Show |
|---|---|
PENDING | "Confirming your payment…" — poll every 2–3 s for up to a minute, then offer to check later |
SUCCEEDED | "N credits added" (tokensGranted / tokensPerCredit), refresh the balance |
FAILED | Payment failed — offer to try again (a new order) |
CANCELLED | The customer backed out — nothing was charged |
EXPIRED | Checkout was never completed (a day passed) |
REFUNDED | Refunded; the tokens were taken back |
GET /v1/topup/orders lists the customer's orders (cursor-paged). Someone else's order id answers 404.
5. Balance and statement
bash
curl http://localhost:4040/v1/credits/balance -H "Authorization: Bearer $TOK"json
{
"tokens": 5500000,
"credits": 550,
"tokensPerCredit": 10000,
"updatedAt": "2026-09-15T10:03:40.000Z",
"totals": { "purchased": 5000000, "bonus": 500000, "granted": 1000000, "converted": 0, "adjusted": 0, "spent": 1000000 }
}Show credits (tokens / tokensPerCredit, rounded down to 2 decimals). Both come with the balance, so there's no need to read /v1/topup/options for the ratio. GET /v1/subscriptions/credits/balance returns the same fields without totals.
bash
curl 'http://localhost:4040/v1/credits/transactions?limit=20' -H "Authorization: Bearer $TOK"kind | Sign | Meaning |
|---|---|---|
GRANT | + | Plan allowance for the cycle, or a support grant |
PURCHASE | + | A top-up (paymentId set) |
PROMO | + | A top-up's bonus (same paymentId as its purchase), or a promotion |
USAGE | − | A generation. Reserved before the call; a second USAGE row appears if it ran over |
REFUND | + | A failed generation, or a reservation that came in under the real cost |
ADJUSTMENT | − / + | A refunded top-up taken back (paymentId set), or a correction |
CONVERSION | + | The pre-token balance, converted once |
Group a USAGE with an immediately following REFUND of the same generation and show the net; show a PROMO under the PURCHASE with the same paymentId.
6. Cost hints before generating
bash
curl http://localhost:4040/v1/credits/rates| Kind | Estimate |
|---|---|
| Image | rates.image × count |
| Video | rates.second × durationSeconds |
| Speech | rates.1k_chars × chars / 1000, or rates.minute × minutes |
| Music | rates.generation |
| Text | (inputTokens × rates.1m_input_tokens + maxTokens × rates.1m_output_tokens) / 1e6 |
Or ask the server, which uses the estimator the gateway reserves with:
bash
curl -X POST http://localhost:4040/v1/credits/estimate -H 'Content-Type: application/json' \
-d '{ "model": "gemini/veo-3.1-lite-generate-preview", "durationSeconds": 8 }'7. Running out
A generation the balance cannot cover is refused before it runs — HTTP 429 QUOTA_EXCEEDED with details.shortfallTokens. Deep-link to the builder with the credits preselected: ceil(shortfallTokens / tokensPerCredit), raised to the component's minUnits and rounded up to its step.
Migrating
GET /v1/credits/pricing,GET /v1/credits/quote,GET /v1/credits/packsand checkout'stopUpCents/packIdare gone. Use/v1/topup.GET /v1/subscriptions/credits/packsreturns[].- From the bucket wallet: read
tokens(the wallet) instead oftextRequests,imageCredits,videoCredits; existing balances converted automatically (oneCONVERSIONrow).