Reseller API
Sell Nitro claims to your own customers. With this API your shop, website or Discord bot can put your customers' accounts in our claim queue, pay for them from your credits, and follow every order until it is done.
Base URL: https://iancu.services/api/v1/reseller
Every request and response is JSON over HTTPS.
How it works
There are four ideas to know. Everything else in this page builds on them.
- Credits. You buy claim credits from us. One credit pays for one claim. Your balance is
on the Reseller page of your dashboard and at
GET /balance. - Orders. An order puts one Discord account in the claim queue for a number of claims. You pay for all of them when you place it. The order then waits its turn, and claims land on the account as they come in. If it leaves the queue before it gets them all - you cancel it, or the account stops working - every claim it did not get is refunded.
- Events. Everything that happens to your orders and credits is recorded as an event, numbered 1, 2, 3 and so on. You can read them from the event feed, and we can also push them to your server as webhooks. This is how you learn that a claim landed or an order finished.
- Customer credits (optional). You can hold credits for each of your customers, so they can spend them on their own orders - for example through your Discord bot. See customer credits.
Quick start
-
Get a key. Make an API key on the Reseller page of your dashboard. It is shown only once, so copy it somewhere safe. While you build, make a test key instead - it never touches the real queue or your real credits (see test mode). Not a reseller yet? The same page is where you apply: we set reseller accounts up by hand, for shops buying their credits in bulk.
-
Check your balance.
curl https://iancu.services/api/v1/reseller/balance \ -H "Authorization: Bearer isk_test_…"{ "credits": 1000, "customerCredits": 0, "openOrders": 0, "lastSeq": 0 } -
Place an order for 5 claims on an account. The
Idempotency-Keymakes it safe to retry (see idempotency).curl -X POST https://iancu.services/api/v1/reseller/orders \ -H "Authorization: Bearer isk_test_…" \ -H "Idempotency-Key: shop-order-981" \ -H "Content-Type: application/json" \ -d '{ "token": "MTAwMDAwMDAwMDAwMDAwMDAx.TESTOK.reseller-api-test-token-not-a-real-one", "claims": 5 }' -
Follow it. Read the event feed from where you last stopped. With
wait=30, the request waits up to 30 seconds for something new instead of answering empty straight away.curl "https://iancu.services/api/v1/reseller/events?after=0&wait=30" \ -H "Authorization: Bearer isk_test_…"You will see
balance.changed(5 credits spent) andorder.created. With a test key, simulate claims landing to see the rest. -
Go live. When everything works, make a live key (
isk_live_…) and use real tokens.
Authentication
Send one of your API keys with every request:
Authorization: Bearer isk_live_…
Managing keys. You make, reset and revoke keys on the Reseller page of your dashboard. Support can revoke a key for you too.
- A key is shown once, when it is made. We cannot show it again.
- If a key leaks or gets lost, reset it: the old key stops working at the same moment the new one starts, so nothing goes down in between. Or revoke it to stop it for good.
- Use a separate key for each program (your shop, your bot, …), so you can reset one without touching the others. You can have up to 10 keys at a time.
Scopes. Each key only does what it was given when it was made:
| Scope | What the key can do |
|---|---|
orders:read |
read your balance, the queue, orders, events and customers |
orders:write |
place orders, replace their tokens, cancel them, and change customer credits |
Test keys. A key made as a test key starts with isk_test_ and works in
test mode: the same endpoints and the same answers, but with test orders and a
test balance, never the real queue.
Wrong keys. A request with a missing or invalid key gets 401. If one IP address sends 20
of those within a minute, further invalid keys from it get 429 for the rest of that minute
(for IPv6, the whole /64 counts as one address). Valid keys from that address keep working.
Basics
Errors
Every error has the same shape. Use code in your program - codes never change meaning.
The message is written for people and may be reworded.
{
"error": {
"code": "insufficient_credits",
"message": "You do not have enough nitro claims for that"
}
}
All codes are listed in error codes.
Rate limits
- Up to 120 requests a minute per key.
- Your account also has a limit on how many orders you can place a minute.
Going over either gets 429 with a Retry-After header: the number of seconds to wait
before trying again.
There is no limit on how many of your orders can be in the queue at once. One order can be for up to 1000 claims, as long as you have the credits.
Idempotency
Requests that spend or move credits need an Idempotency-Key header: placing an order,
changing a customer's credits, and moving credits between customers.
The problem it solves: your request times out, and you cannot tell whether it went through. If you send it again, did you just place two orders? With an idempotency key, no.
- Pick a new key for each thing you mean to do - for example your own order or payment id.
8 to 100 characters: letters, digits and
. _ : -. - If you send the same request again with the same key, nothing happens twice. You get back
the result of the first one, with
"replayed": true. - If you send the same key with a different request (another token, amount, customer, …),
you get
409 idempotency_conflict.
Other details
- Times are ISO 8601, in UTC - for example
2026-09-28T17:03:05.000Z. - Nothing is cached. Every response is sent with
Cache-Control: no-store.
Orders
An order asks us to claim Nitro onto one Discord account. You give us the account's token and the number of claims you want. We check the token with Discord, take the credits from your balance, and put the order in the queue.
The token is only used to claim onto that account. We never return it: it is stored encrypted while the order is in the queue and deleted as soon as the order leaves.
Which orders. Your orders here are the ones placed through the API or your bot. Orders
your account places on our website are not part of the API: they are not listed or counted,
they have no events or webhooks, your bot does not announce them, and they cannot be changed
through it. The credits they spend still come off your balance, so balance.changed still
tells you - without an orderId.
The order object
{
"id": 1234,
"reference": "shop-order-981",
"state": "pending",
"account": "327634222124761090",
"claims": 5,
"received": 2,
"place": 3,
"nextClaimEta": { "min": "2026-09-28T19:40:00.000Z", "max": "2026-09-28T22:15:00.000Z" },
"reason": null,
"customer": "401234567890123456",
"paidFrom": "balance",
"createdAt": "2026-09-28T17:03:05.000Z",
"updatedAt": "2026-09-28T18:10:41.000Z"
}
| Field | Meaning |
|---|---|
id |
our id for the order |
reference |
your own id for it, if you gave one; otherwise null |
state |
where the order is - see below |
account |
the Discord user id the claims go to (read from the token you sent) |
claims |
how many claims the order is for |
received |
how many of them have landed so far |
place |
its position in the whole queue, where 1 is next in line; null once it has left the queue |
nextClaimEta |
when its next claim is likely to land: between min and max - see below; otherwise null |
reason |
if it left early, why - written so you can show it to your customer; otherwise null |
customer |
the customer it is for, if you named one |
paidFrom |
who paid: balance (your credits) or customer (the credits you hold for that customer) |
States. An order starts pending and ends in one of the other three:
| State | Meaning |
|---|---|
pending |
in the queue, waiting for its claims |
completed |
every claim has landed - done |
refunded |
it left the queue early, and the claims it did not get were refunded to whoever paid |
removed |
it left the queue early with nothing refunded |
When the next claim lands. nextClaimEta is the window the dashboard shows as "in
2–4 h": two ISO 8601 times, min and max, and the claim lands between them about 8 times
in 10. It is the next claim, not the last one - the queue takes turns, so after each claim the
order goes to the back of the line and waits its turn again.
It is worked out from how fast claims landed over the last week and the order's place, so it
moves as the queue does: read it fresh rather than keeping it. It is null when there is no
saying - nothing has landed for a day, too little landed last week to go on, or the order has
left the queue.
Place an order
POST /orders · scope orders:write · needs an Idempotency-Key
Puts an account in the queue. The credits are taken as soon as the order is placed.
| Body field | Required | Meaning |
|---|---|---|
token |
yes | the Discord token of the account to claim onto. It may be inside other pasted text - we find it |
claims |
yes | how many claims, from 1 to 1000 |
reference |
no | your own id for the order, so you can find it later: up to 100 letters, digits and . _ : -, unique among your orders |
customer |
no | the customer it is for. If it is their Discord id, your bot shows them the order and messages them about it |
paidFrom |
no | balance (the default) pays from your credits. customer pays from the credits you hold for customer |
POST /orders
Idempotency-Key: shop-order-981
Content-Type: application/json
{ "token": "…", "claims": 5, "reference": "shop-order-981" }
Response: 201 with the new order.
{ "order": { "id": 1234, "state": "pending", "claims": 5, "received": 0, "place": 20, … } }
A retry with the same idempotency key answers 200 with the same order and
"replayed": true.
Paying from a customer's credits. With "paidFrom": "customer", you must also send
customer. The order spends that customer's credits instead of yours, and anything it does
not receive goes back to them. If they do not hold enough, you get
402 insufficient_customer_credits.
Common errors: 402 insufficient_credits (your balance is too low),
422 token_invalid (not a token, or Discord refused it), 422 token_locked (the account
must be verified first), 409 account_already_queued (that account already has an order
waiting), 409 reference_in_use.
The checks run in this order: whether whoever pays has enough credits, whether the account is already queued, and only then the token with Discord - so an order you cannot pay for never sends a token anywhere.
List orders
GET /orders · scope orders:read
Your orders, newest first.
| Query | Meaning |
|---|---|
state |
only orders in this state: pending, completed, refunded or removed |
reference |
only the order with this reference |
customer |
only orders for this customer |
updatedSince |
only orders that changed (state or claims received) after this time |
limit |
how many to return, 1 to 100 (default 25) |
cursor |
where to continue from - the nextCursor of the page before |
{ "orders": [ … ], "nextCursor": 1180 }
To get the next page, send nextCursor back as cursor. When nextCursor is null,
there are no more pages.
Get an order
GET /orders/{id} · scope orders:read
One order, with a list of the claims that have landed on it and counted towards it. For each
claim: its type, its name - the type in words, like Nitro Monthly - how long it took to
claim in milliseconds (delayMs), and when it landed. A gift that was not Nitro does not
count, so it is not in this list (see claim.landed).
{
"order": {
"id": 1234,
…,
"claimsLanded": [
{ "type": "NITRO_MONTHLY", "name": "Nitro Monthly", "delayMs": 2100, "at": "2026-09-28T18:10:41.000Z" }
]
}
}
Replace an order's token
PUT /orders/{id}/token · scope orders:write
If an account's token stops working while its order waits (for example, the customer changed their password), send a new one. The order keeps its place in the queue.
{ "token": "…" }
The new token must be for the same account, or you get 422 different_account.
Returns { "order": … }.
Cancel an order
DELETE /orders/{id} · scope orders:write
Takes the order out of the queue. The claims it has not received are refunded to whoever paid - your balance, or the customer's credits.
{ "order": { …, "state": "refunded" }, "refunded": 3 }
A refund only ever happens once. If the order has already left the queue - including when
two cancel requests are sent at the same moment - you get 409 not_in_queue and nothing is
refunded again.
Simulate a test order
POST /orders/{id}/simulate · scope orders:write · test keys only
Makes something happen to a test order, so you can see how your program
handles it. Send { "result": "…" }, or no body for a claim.
result |
What happens |
|---|---|
claim |
a claim lands. The last one completes the order |
other |
a gift that is not Nitro lands: claim.landed with claim.counts false. The order does not move |
captcha |
the account keeps getting a captcha, so the order is released |
token_invalid |
the token stops working, so the order is released |
token_locked |
the account needs verifying, so the order is released |
You get the same events a real order would produce. Returns { "order": … }, plus
refunded when the order was released. With a live key this is 403 test_mode_only.
Balance and queue
Check your balance
GET /balance · scope orders:read
{ "credits": 47, "customerCredits": 58, "openOrders": 1, "lastSeq": 1289 }
| Field | Meaning |
|---|---|
credits |
your credits: what you can spend on orders or give to customers |
customerCredits |
the total you hold for your customers. It is theirs, not yours to spend |
openOrders |
how many of your API and bot orders are in the queue |
lastSeq |
the number of the newest event. A program starting fresh reads the feed from here |
See the queue
GET /queue · scope orders:read
How long the whole queue is, and where each of your orders stands in it.
{
"length": 20,
"places": [
{
"orderId": 1234,
"place": 3,
"nextClaimEta": { "min": "2026-09-28T19:40:00.000Z", "max": "2026-09-28T22:15:00.000Z" }
}
]
}
Claim stats
GET /stats · scope orders:read
Our Nitro claim stats - the same figures our Nitro stats page shows - for showing your own customers what lands, how fast, and how long a new order would wait. Counts, types, delays and times only: nothing about any account. A test key gets the same real stats.
Every option is optional. GET /stats on its own gives the last 30 days, one point per day,
counted in UTC. To show something else, add options as a query string:
curl "https://iancu.services/api/v1/reseller/stats?bucket=hourly&timeZone=Europe/Bucharest" \
-H "Authorization: Bearer isk_live_…"
Pick what you want to show:
| To show | Ask for |
|---|---|
| today, hour by hour | /stats?bucket=hourly |
| one particular day, hour by hour | /stats?bucket=hourly&day=2026-10-06 |
| the last 7 days, day by day | /stats?periods=7 |
| the last 30 days, day by day | /stats |
| the last 12 weeks, week by week | /stats?bucket=weekly |
| the last year, week by week | /stats?bucket=weekly&periods=52 |
| any of these, with your customers' "today" | add &timeZone=America/New_York |
The options:
bucket- how much time each point inseriescovers:hourly,dailyorweekly. Defaultdaily. It also decides whattotals.currentis: this hour, today, or this week. Weeks start on Monday.periods- how many pointsserieshas, counting back from now and including the current one: withdailythat many days, withweeklythat many weeks. A whole number from 1 to 168. Default 30 days or 12 weeks. Not withhourly, which is always the 24 hours of one day - there it is refused.day- withbucket=hourlyonly: which day's 24 hours, asYYYY-MM-DD. Default today. A day still to come shows today. With any otherbucketit is refused.timeZone- whose clock "today", "this week" and every hour are counted on, as an IANA zone name:Europe/Bucharest,America/New_York,Asia/Singapore. DefaultUTC. Use the zone your customers are in, so "today" is their today. It only moves where days and hours begin: every time in the reply is still in UTC (ending inZ).
Anything else - an unknown bucket or zone, periods out of range, a date that does not
exist, an option with a bucket it does not go with - is refused with 400 bad_request,
saying which option is wrong.
The reply starts with the options it was counted with, defaults filled in - day is the day
shown for hourly and null otherwise - then the figures:
{
"bucket": "daily",
"periods": 30,
"day": null,
"timeZone": "UTC",
"totals": { "all": 2716, "window": 412, "current": 9 },
"series": [
{ "start": "2026-09-08T00:00:00.000Z", "claims": 14 },
{ "start": "2026-10-07T00:00:00.000Z", "claims": 9 }
],
"types": [
{ "type": "NITRO_MONTHLY", "count": 301, "share": 73.1 },
{ "type": "NITRO_BASIC_MONTHLY", "count": 96, "share": 23.3 }
],
"recent": [
{
"type": "NITRO_MONTHLY",
"name": "Nitro Monthly",
"delayMs": 2100,
"at": "2026-10-07T09:12:41.000Z"
}
],
"speed": { "perHour": 1.84, "lastClaimAt": "2026-10-07T09:12:41.000Z" },
"queue": {
"length": 20,
"joinNowEta": { "min": "2026-10-07T18:40:00.000Z", "max": "2026-10-08T02:15:00.000Z" }
}
}
| Field | Meaning |
|---|---|
totals.all |
every claim ever |
totals.window |
claims across series |
totals.current |
claims in the hour, day or week in progress |
series |
one point per hour, day or week, oldest first. start is when it begins. claims is null for an hour of today that has not come yet |
types |
claims in series by type, most first, with share as a percentage. Types as in the order object |
recent |
the 50 latest claims, newest first - whenever they were, not only inside series. A gift that was not Nitro is in it too, as OTHER with its name |
speed.perHour |
Nitro claims an hour over the last 7 days |
speed.lastClaimAt |
when the latest Nitro claim landed, or null before the first |
queue.length |
how many orders are waiting |
queue.joinNowEta |
when an order placed now would likely get its first claim, worked out like nextClaimEta: it goes to the back of the queue. null when nothing has landed for 24 hours or too little has landed lately |
Events
Every change to your orders and credits is an event. Events are numbered for you in
order - 1, 2, 3 - and no number is ever skipped. That number is seq.
There are two ways to get them:
- The feed -
GET /events. You ask for everything after the last event you handled. This is the complete record. - Webhooks - we
POSTeach event to your server as it happens. Faster, but not guaranteed. See webhooks.
Always read the feed, even if you use webhooks. A webhook can fail, arrive late, or be switched off after too many failures. If the last webhook before a quiet period is lost, there is no later event to show you something is missing. Only the feed catches that. See keeping in sync.
The event object
{
"id": "evt_cmulrlj4m0003tjectwg8h04g",
"seq": 1289,
"test": false,
"type": "claim.landed",
"createdAt": "2026-09-28T18:10:41.000Z",
"data": {
"order": {
"id": 1234,
"reference": "shop-order-981",
"state": "pending",
"account": "327634222124761090",
"claims": 5,
"received": 3,
"reason": null,
"customer": "401234567890123456",
"paidFrom": "customer"
},
"claim": {
"type": "NITRO_MONTHLY",
"name": "Nitro Monthly",
"delayMs": 2100,
"at": "2026-09-28T18:10:41.000Z",
"counts": true
}
}
}
test is true for test mode events. data.order is the order as it was when
the event happened - the order object without place or nextClaimEta, since those
change whenever anyone else's order moves. Every event type is shown in full under
examples.
Event types
| Type | When it happens | What is in data |
|---|---|---|
order.created |
an order was placed | order |
claim.landed |
a gift landed on the account - Nitro or not, see claim.counts |
order, claim |
order.completed |
the order received all its claims | order |
order.released |
the order left the queue early | order, cause, refunded (the claims given back) |
order.token_updated |
the order's token was replaced | order |
balance.changed |
your credits went up or down | balance |
customer.credits_changed |
a customer's credits went up or down | customer, change, cause, orderId, counterpart, and order when it was for one |
Failed claim attempts are not events - only things that change something are.
Why an order was released (cause in order.released):
cause |
Meaning |
|---|---|
left |
you (or your customer) cancelled it |
removed |
we removed it |
suspended |
your reseller account was suspended |
token_invalid |
the token stopped working |
token_locked |
the account needs verifying |
captcha |
the account was given a captcha three times in a row |
The order's reason says the same thing in words you can show your customer.
Balance changes. In balance.changed, balance looks like this:
{ "credits": 47, "change": -5, "cause": "spent", "orderId": 1234 }
credits is your new balance and change is how much it moved. cause is one of:
purchased (you bought credits), spent (on an order), refunded (from an order),
adjusted (changed by us), given (to a customer) or taken_back (from a customer).
orderId is there when the change was for one of your API or bot orders. Credits spent or
refunded by an order placed on the website show without one.
Placing an order produces balance.changed (spent) first, then order.created.
Customer credit changes. In customer.credits_changed, customer is the customer after
the change, like { "id": "401234567890123456", "credits": 7 }, and change is how much
moved (negative when it went down). cause is one of:
givenortaken_back- you changed their credits. This comes right after your ownbalance.changed.spent- on the orderorderId. This comes before that order'sorder.created.refunded- from the orderorderId. This comes before that order'sorder.released.transferred_outortransferred_in- moved to or from another customer, named incounterpart.
Examples
One order's life, as the feed shows it: you give a customer 5 credits, they spend them on an order, claims land, they replace the token, and the token stops working so the order is released with the rest refunded. Then another order completes. Each is the whole event, as the feed and webhooks send it.
balance.changed - you gave a customer 5 credits, so your balance went down by 5.
{
"id": "evt_cmulrk1x20001tjec8f3a0b1c",
"seq": 1284,
"test": false,
"type": "balance.changed",
"createdAt": "2026-09-28T17:00:00.000Z",
"data": {
"balance": {
"credits": 42,
"change": -5,
"cause": "given",
"orderId": null
}
}
}
customer.credits_changed (given) - right after it: what the customer holds now.
{
"id": "evt_cmulrk2x20001tjec8f3a0b1c",
"seq": 1285,
"test": false,
"type": "customer.credits_changed",
"createdAt": "2026-09-28T17:00:00.000Z",
"data": {
"customer": {
"id": "401234567890123456",
"credits": 5
},
"change": 5,
"cause": "given",
"orderId": null,
"counterpart": null
}
}
customer.credits_changed (spent) - the customer spends their 5 credits on an order. A change for an order also carries the order, as it was.
{
"id": "evt_cmulrk3x20001tjec8f3a0b1c",
"seq": 1286,
"test": false,
"type": "customer.credits_changed",
"createdAt": "2026-09-28T17:03:05.000Z",
"data": {
"order": {
"id": 1234,
"reference": "shop-order-981",
"state": "pending",
"account": "327634222124761090",
"claims": 5,
"received": 0,
"reason": null,
"customer": "401234567890123456",
"paidFrom": "customer"
},
"customer": {
"id": "401234567890123456",
"credits": 0
},
"change": -5,
"cause": "spent",
"orderId": 1234,
"counterpart": null
}
}
order.created - the order is in the queue.
{
"id": "evt_cmulrk4x20001tjec8f3a0b1c",
"seq": 1287,
"test": false,
"type": "order.created",
"createdAt": "2026-09-28T17:03:05.000Z",
"data": {
"order": {
"id": 1234,
"reference": "shop-order-981",
"state": "pending",
"account": "327634222124761090",
"claims": 5,
"received": 0,
"reason": null,
"customer": "401234567890123456",
"paidFrom": "customer"
}
}
}
claim.landed - a claim landed - the second one, so received is 2. claim.counts is
true: it counts towards the order.
{
"id": "evt_cmulrk5x20001tjec8f3a0b1c",
"seq": 1288,
"test": false,
"type": "claim.landed",
"createdAt": "2026-09-28T18:10:41.000Z",
"data": {
"order": {
"id": 1234,
"reference": "shop-order-981",
"state": "pending",
"account": "327634222124761090",
"claims": 5,
"received": 2,
"reason": null,
"customer": "401234567890123456",
"paidFrom": "customer"
},
"claim": {
"type": "NITRO_MONTHLY",
"name": "Nitro Monthly",
"delayMs": 2100,
"at": "2026-09-28T18:10:41.000Z",
"counts": true
}
}
}
A gift that was not Nitro lands too, as claim.landed with claim.type OTHER,
claim.counts false, and claim.name saying what it was - "Cat Ears", or "Other" if
Discord did not say. Every claim has a name: for Nitro it is the type in words, like
"Nitro Monthly". It is the customer's, but not what they ordered: it does not count
towards the order, so received stays where it was, the order keeps its place in the queue,
and it is not in the order's claimsLanded. Tell your customer they got a gift - and that
their order is still waiting.
order.token_updated - the customer gave a new token for the same account. The order keeps its place.
{
"id": "evt_cmulrk6x20001tjec8f3a0b1c",
"seq": 1289,
"test": false,
"type": "order.token_updated",
"createdAt": "2026-09-28T18:30:12.000Z",
"data": {
"order": {
"id": 1234,
"reference": "shop-order-981",
"state": "pending",
"account": "327634222124761090",
"claims": 5,
"received": 2,
"reason": null,
"customer": "401234567890123456",
"paidFrom": "customer"
}
}
}
customer.credits_changed (refunded) - the token stopped working, so the order was released. First the 3 claims it did not get go back to the customer.
{
"id": "evt_cmulrk7x20001tjec8f3a0b1c",
"seq": 1290,
"test": false,
"type": "customer.credits_changed",
"createdAt": "2026-09-28T19:02:57.000Z",
"data": {
"order": {
"id": 1234,
"reference": "shop-order-981",
"state": "refunded",
"account": "327634222124761090",
"claims": 5,
"received": 2,
"reason": "The account token you provided got invalid and could not sign in any more, so it was taken out of the queue and your remaining claims were put back on your balance.",
"customer": "401234567890123456",
"paidFrom": "customer"
},
"customer": {
"id": "401234567890123456",
"credits": 3
},
"change": 3,
"cause": "refunded",
"orderId": 1234,
"counterpart": null
}
}
order.released - then the release itself, with why and how many claims were given back.
{
"id": "evt_cmulrk8x20001tjec8f3a0b1c",
"seq": 1291,
"test": false,
"type": "order.released",
"createdAt": "2026-09-28T19:02:57.000Z",
"data": {
"order": {
"id": 1234,
"reference": "shop-order-981",
"state": "refunded",
"account": "327634222124761090",
"claims": 5,
"received": 2,
"reason": "The account token you provided got invalid and could not sign in any more, so it was taken out of the queue and your remaining claims were put back on your balance.",
"customer": "401234567890123456",
"paidFrom": "customer"
},
"cause": "token_invalid",
"refunded": 3
}
}
order.completed - another order - placed from your own balance, for nobody in particular - got its last claim. A claim.landed for that claim comes just before.
{
"id": "evt_cmulrk9x20001tjec8f3a0b1c",
"seq": 1294,
"test": false,
"type": "order.completed",
"createdAt": "2026-09-28T20:41:09.000Z",
"data": {
"order": {
"id": 1240,
"reference": "shop-order-990",
"state": "completed",
"account": "327634222124761091",
"claims": 1,
"received": 1,
"reason": null,
"customer": null,
"paidFrom": "balance"
}
}
}
Read events
GET /events · scope orders:read
Events after a given seq, oldest first.
| Query | Meaning |
|---|---|
after |
the seq of the last event you handled. Use 0 to start from the beginning |
limit |
how many to return, 1 to 100 (default 100) |
wait |
if there is nothing new, wait up to this many seconds (0 to 30) for an event before answering |
{ "events": [ … ], "lastSeq": 1291, "hasMore": false }
Send lastSeq back as after in your next request. If hasMore is true, there are more
events waiting - ask again straight away.
Waiting for events. With wait, a request that finds nothing stays open until an event
happens or the time is up. Ask again as soon as it answers, and you hear about events about
as fast as a webhook would, with no server of your own needed. You can have up to two of
these waiting requests open at once for live keys, and two more for test keys.
The feed acknowledges webhooks. When you ask for events after a seq, we take it that
you have every event up to that number, and stop retrying their webhooks.
Past the newest event. Asking from a seq newer than the newest event gets
400 bad_request. That is what a saved test position gets after test mode is started over:
read from lastSeq in GET /balance again.
Events are kept for 30 days. Asking from a seq older than that gets 410 events_expired.
To recover, get the orders that changed with GET /orders?updatedSince=…, then read the
feed on from the newest event (lastSeq in GET /balance).
Keeping in sync
To make sure your records always match ours:
- Handle events in
seqorder, and save theseqof the last one you handled. - Keep reading the feed, even with webhooks. Keep a
wait=30request open and ask again each time it answers - or, at the very least, read it once a minute. - If a webhook arrives with a
seqthat is not the next one you expect, one went missing. Read the feed from your last handledseqto fill the gap. - When your program starts, or comes back after being down, read the feed before trusting webhooks again.
- If you get an event whose
seqyou have already handled - a retried webhook, or one that arrives after the feed already gave it to you - ignore it. - If in doubt,
GET /orders/{id}gives you the order as it is right now.
Webhooks can arrive out of order, because a retry can land after a newer event. Handling
events by seq makes that harmless.
Customer credits
You can hold credits for each of your customers. When somebody pays you, you give them credits; later, they (or you) spend them on orders. This lets you keep a credit balance for every customer without building one yourself.
A typical flow:
- A customer pays you 10 credits' worth on your website.
- You give them 10 credits. The 10 come out of your balance straight away.
- You place an order with
"customer": "<their id>"and"paidFrom": "customer". It spends their credits, not yours. - If the order ends early, the claims it did not get go back to the customer.
Because credits leave your balance the moment you give them, you can never promise more than you have, and a customer can always spend what they were given. Taking credits back puts them on your balance again.
Customer ids. A customer's id is one of two things:
- Their Discord user id, like
401234567890123456. Your bot can then show them their orders and credits and message them. - Your own id for somebody you know from your website: up to 100 letters, digits and
. _ : -, and not only digits - for examplesite-42,u42orcus_8f3k. If your database numbers its customers, add a prefix to the number.
An id made only of digits is always treated as a Discord id. That rule makes sure one of your own ids can never be mistaken for a stranger's Discord account, which the bot would message. The bot never messages or mentions anyone by your own id; it just shows the id as text.
The customer object is simply { "id": "401234567890123456", "credits": 7 }. A customer
you have never given anything to holds 0.
List customers
GET /customers · scope orders:read
Customers who hold credits, the most credits first.
| Query | Meaning |
|---|---|
limit |
how many to return, 1 to 100 (default 25) |
offset |
how many to skip, for the next page |
{
"customers": [{ "id": "401234567890123456", "credits": 7 }],
"total": { "customers": 12, "credits": 58 }
}
Get a customer
GET /customers/{id} · scope orders:read
{ "customer": { "id": "401234567890123456", "credits": 7 } }
Give or take back credits
POST /customers/{id}/credits · scope orders:write · needs an Idempotency-Key
A positive change gives the customer credits from your balance. A negative one takes
credits back from them onto your balance. Up to 100000 either way.
POST /customers/401234567890123456/credits
Idempotency-Key: shop-payment-5521
Content-Type: application/json
{ "change": 5 }
{ "customer": { "id": "401234567890123456", "credits": 12 }, "change": 5 }
customer is the customer after the change.
- Giving more than your balance holds:
402 insufficient_credits. - Taking back more than the customer holds:
402 insufficient_customer_credits.
Either error's message says how many credits there are.
Move credits between customers
POST /customers/{id}/transfer · scope orders:write · needs an Idempotency-Key
Moves credits from this customer to another one. Your own balance does not change.
{ "to": "401234567890123457", "credits": 2 }
Returns { "from": …, "to": … } - both customers after the move. If this customer holds too
few, you get 402 insufficient_customer_credits.
Webhooks
Set an HTTPS address on the Reseller page of your dashboard, and we
will POST every event to it as it happens - the same event object the feed
gives you.
A webhook is a quick notice, not a guarantee. Keep reading the feed as well, or an event whose webhook never arrives is one you never hear about.
What we send
Each webhook is a POST with a JSON body, sent from a Cloudflare address:
POST /your/endpoint
Content-Type: application/json
User-Agent: IancuServices-Webhooks/1.0
Iancu-Event-Id: evt_cmulrlj4m0003tjectwg8h04g
Iancu-Event-Type: claim.landed
Iancu-Signature: t=1790596800,v1=5257a869…
{ "id": "evt_cmulrlj4m0003tjectwg8h04g", "seq": 1289, "test": false, "type": "claim.landed", "createdAt": "…", "data": { … } }
Test mode events go to the same address, with "test": true. The Send test webhook
button sends a ping event, which has no seq and is not in the feed. The first few go 15
seconds apart; the wait grows with each one sent in the past day, up to 20 a day.
Answering
Reply with any 2xx status once you have the event. Anything else - including a redirect -
counts as not received. We never follow redirects and never read your response body.
Retries
If a webhook is not received, we try again after about 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then give up. If you read that event from the feed in the meantime, we stop retrying it.
If nothing reaches your address for a whole day, webhooks are switched off. We tell you once, by email and in your notifications. Turn them back on from the Reseller page when your server is fixed - the feed has everything that happened in between.
The Reseller page also lists your latest webhooks and how your server answered each one. You can Send again any of them - the first few 15 seconds apart, the wait growing with each one sent in the past day, up to 20 a day (counted apart from test webhooks).
Which addresses are allowed
The address must be https:// on a public domain name, on the standard port. It cannot be:
- an IP address,
localhostor another private name,- a name ending in a dot,
- on
iancu.services.
Checking a webhook came from us
Anyone can send a request to your address, so check the signature before trusting it.
The signing secret (whsec_…) is shown once, when you first set an address, and again
each time you reset it. The Iancu-Signature header looks like t=<time>,v1=<signature>:
tis the time it was sent, in Unix seconds.v1is the HMAC-SHA256, in hex, of<t>.<raw body>, using your signing secret.
Check it against the raw body, before parsing it, and refuse anything more than five minutes old. In Node.js:
import { createHmac, timingSafeEqual } from "node:crypto"
function fromIancu(rawBody, header, secret, now = Date.now()) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")))
const t = Number(parts.t)
if (!Number.isInteger(t) || Math.abs(now / 1000 - t) > 300) return false
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest()
const given = Buffer.from(parts.v1 ?? "", "hex")
return given.length === expected.length && timingSafeEqual(given, expected)
}
The same event can arrive more than once. Ignore any whose seq you have already handled.
Test vectors
To test your signature check before a real webhook arrives, run it on these and compare. The
secret is whsec_test_4bJ8kq2VfX9mZtR3yL6wN1pD and the time is 1790596800:
| Raw body | Iancu-Signature |
|---|---|
{"id":"evt_test","seq":1,"type":"ping"} |
t=1790596800,v1=6d5aa01ee2eee460106e5fd0afa61fe1856b3ac5da0a6e0fe3a7e7dfb42c8f4c |
{"note":"Café ☕"} |
t=1790596800,v1=1368334ff995b929d45b6da3d084b9572c8e35555832ce560092ce8404eff3ed |
The second one checks that you sign the body's bytes exactly as they arrived, as UTF-8. Code that decodes and re-encodes the body, or reads it in another encoding, gets the first one right and this one wrong.
Pass the time in yourself (the now argument above): these vectors are old, so the
five-minute check would refuse them.
Test mode
A test key (isk_test_…) is for building and trying out your program. It works exactly like
a live key - scopes, idempotency, errors, events and webhooks - but in a separate world of
its own:
- A test balance of 1000 credits, spent and refunded like the real one.
- Test orders, which never enter the real queue. They only move when you simulate something, so you can produce every event on demand.
- Test customer credits, given from the test balance and spent on test orders, kept apart from what your real customers hold.
- A next-claim time on each waiting test order, worked out at the real queue's speed as if its place in your test line were its place in the real one.
- Its own feed. Test events have
"test": trueand are numbered separately, starting from 1. Keep one savedseqfor live events and another for test events. They go to the same webhook address.
Test tokens. Never send a real token with a test key - use these instead. They are shaped exactly like real tokens, so any code that checks a token's format or reads the account id out of it works on them too. Only the middle part marks them as test tokens.
| Token | Result |
|---|---|
MTAwMDAwMDAwMDAwMDAwMDAx.TESTOK.reseller-api-test-token-not-a-real-one |
works, for account 100000000000000001 |
MTAwMDAwMDAwMDAwMDAwMDAy.TESTOK.reseller-api-test-token-not-a-real-one |
works, for account 100000000000000002 |
MTAwMDAwMDAwMDAwMDAwMDA5.TESTIV.reseller-api-test-token-not-a-real-one |
refused with 422 token_invalid |
MTAwMDAwMDAwMDAwMDAwMDA5.TESTLK.reseller-api-test-token-not-a-real-one |
refused with 422 token_locked |
To make a test token for another account:
- Take an account id (any 17 to 19 digits) and base64url-encode it.
- Add
.TESTOK.- or.TESTIV./.TESTLK.for a token that is refused. - Add any 38 letters, digits,
-or_.
Like a real token, the first part says which account the order is for, and the token is
found even inside other pasted text. Queueing the same account again while its test order
waits gets 409 account_already_queued.
Starting over. The Reseller page shows your test balance, and its Start over button resets test mode: test orders, customer credits and events are deleted, the balance goes back to 1000, and the test feed starts again from 1.
Discord bot
We can run a Discord bot for your shop, under your shop's name and picture. It works in your server and in DMs, and your customers use it to check their credits, queue their accounts and follow their orders - all backed by this API.
Prefer to host it yourself? For full freedom over how your bot looks and what it does, fork our example self-hosted bot - it is built on this same API - and make it your own.
Connecting it
- Create an application at discord.com/developers.
- On its Bot page, copy the bot token.
- Paste the token on the Reseller page. We register the commands and point the application at us.
- Use the Add to your server link the page gives you.
- Put your own Discord user id in Super Administrators on the same page.
The bot needs no gateway intents. Discord sends its commands to us over HTTP, so the Privileged Gateway Intents on the Bot page - Presence, Server Members and Message Content - can all stay off.
Switching to another bot. A bot can only belong to one shop. To move to a different bot, use Switch bot on the Reseller page and paste the new token. Your staff, FAQ, settings, Super Administrators and customers' credits all stay. The old bot's commands and messages are taken down, and once the new bot is in your server it posts the queue, leaderboard and FAQ again in the same channels.
Staff levels
The bot has its own staff levels - they are not Discord roles, so staff commands work the
same in DMs. Super Administrators (the ids on the Reseller page) can do everything, and use
/staff add to give other people a level:
| Level | Can use |
|---|---|
| Moderator | the bank, queueing and removing orders, the FAQ, /balance, /sync, /staff list |
| Administrator | everything a Moderator can, plus /settings |
| Super Administrator | everything, including /staff add and /staff remove |
Commands
Every command also works in a DM with the bot, except the few that pick your server's channels or roles.
| Command | Who can use it | What it does |
|---|---|---|
/bank view [user], or Bank View in the user menu |
anyone | shows how many credits someone holds |
/bank leaderboard |
anyone | who holds the most credits |
/bank history [user] |
customers | every change to their credits, newest first: how much, why and who did it. Staff can see anyone's |
/bank transfer user credits [from] |
anyone | sends some of your credits to someone else. Staff can send from another person with from |
/redeem claims |
customers | opens a private form for their token; the order is paid from their credits |
/update-token [order] |
customers | gives a new token for their waiting account (same account only) |
/leave [order] |
customers | takes their order out of the queue; unreceived claims come back as credits |
/queue view |
anyone | your shop's orders in the queue, with their places |
/queue position [user], or Queue Position in the user menu |
anyone | where someone's orders stand |
/order list [user] · /order info id |
customers | their orders, or one order with its claims by type. Staff can see anyone's |
/faq show · /faq search |
anyone | your questions and answers |
/bank add · /bank remove |
Moderator | gives a customer credits from your balance, or takes them back |
/queue add user claims |
Moderator | opens a private form for a customer's token; paid from your credits |
/queue remove order refund message |
Moderator | takes an order out; with refund, unreceived claims go to the customer |
/faq add · edit · remove · move · channel · update |
Moderator | manages the FAQ and its message in a channel |
/balance · /sync roles |
Moderator | shows your credits and what customers hold; gives everyone the customer role |
/settings |
Administrator | the logs and claims channels, queue and leaderboard messages, roles and emojis; claims as an embed or one line of text; queue places out of the whole queue or the shop's own |
/staff list · add · remove |
Super Administrator | who has which level (Moderators can list) |
Answers are only visible to the person who asked, except /bank view, /queue position and
the FAQ, which answer in the open - the user menus answer privately too. Tokens are only ever
typed into private forms.
What the bot does on its own
- Messages customers when they are given credits, when their order is placed, as claims land, and when the order finishes or ends early. To DM them, the bot has to share a server with them.
- Announces every claim in the claims channel - its type, how long it took, whose it was and how far the order has got, as an embed or one line of text - with the claim ping role and a reaction, if you set them.
- Logs every staff action and everything that happens in the logs channel, each as a card saying who did what.
- Keeps its messages up to date - the queue, leaderboard and FAQ messages it posted - and gives new customers the customer role.
Server setup
- The bot must be able to see and post in the channels you give it, with embeds.
- Its own role must be above the customer role.
- It must be allowed to mention the claim ping role. That is the only role it ever mentions.
Orders placed through your bot are your orders like any others: they spend your credits, count toward your orders-a-minute limit, and show up in your feed and webhooks.
Error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request |
the body or a query parameter is wrong - the message says which |
| 400 | idempotency_key_required |
an order, credit change or transfer was sent without a valid Idempotency-Key |
| 401 | unauthorized |
no API key, or not a valid one |
| 402 | insufficient_credits |
your balance is too low for that order, or to give that many credits |
| 402 | insufficient_customer_credits |
the customer holds too few credits for that order, take-back or transfer |
| 403 | missing_scope |
the key does not have the scope this needs |
| 403 | reseller_suspended |
your reseller account is suspended, so every key is refused |
| 403 | test_mode_only |
simulating needs a test key |
| 404 | not_found |
there is no such endpoint |
| 404 | order_not_found |
you have no order with that id |
| 405 | method_not_allowed |
the endpoint exists, but not with that method (the Allow header lists the right ones) |
| 409 | account_already_queued |
that Discord account already has an order in the queue |
| 409 | not_in_queue |
the order has already left the queue |
| 409 | idempotency_conflict |
that idempotency key was already used for a different request |
| 409 | reference_in_use |
you already have an order with that reference |
| 410 | events_expired |
the feed no longer has events that old - see read events |
| 422 | token_invalid |
not a Discord token, or Discord refused it |
| 422 | token_locked |
the account is locked and must be verified first |
| 422 | different_account |
the new token is for a different account |
| 429 | rate_limited |
too many requests - wait Retry-After seconds |
| 500 | internal_error |
something went wrong on our side. Safe to retry with the same Idempotency-Key |
| 503 | claims_disabled |
Nitro claims are not being sold right now |