Wholesale delivery of digital goods over an API: a catalogue with our wholesale prices, availability, orders, balance and events. Everything you need to connect is on this page; no login required.
Contract version — v1. Changelog — 11-partner-api-changelog.md,
which also holds the deprecation rule.
0. What is open today
We do not write in the documentation what does not exist. The table below is the state as of the date of the latest changelog entry; everything marked "closed" in it is described in §10 in a separate section with the reason.
🔴 THE MOST IMPORTANT THING TO KNOW FIRST: there is one address, and there is no test environment. The address is
https://kodrio.com: you issue a kodrio_live_… key yourself in the dashboard after company verification;
it reads the catalogue, the balance and events, and order intake is not open yet (§10.1). The first order will go
with this same live key (§10.2).
| Capability | State |
|---|---|
| Catalogue with prices, batch availability | ✅ open |
| Balance and ledger of movements | ✅ open |
| Event subscriptions, signature, retries, replay | ✅ open |
| Placing an order POST /v1/orders | ⛔ not open yet — §10.1 |
| Test environment | ⛔ none — §10.2 |
| Collecting an order's codes GET /v1/orders/{id} | ✅ opened 2026-08-07 — §5.5. Can be re-read indefinitely |
| Order list GET /v1/orders | ✅ opened 2026-09-07 — §5.6. Cursor pagination; an order with no money movement IS in the list |
Recipient check /v1/recipients/check | ⛔ not built — §10.4 |
The order contract (§5) is fully described and will not change. Opening live order intake will go into the changelog as a separate entry.
1. Quick start
One path, no branching. Steps 1–4 describe endpoints that are built and can be walked with a live key right now; step 5 waits for order intake to open (§10.1), and step 6 follows it.
What you need before you start
| What | Value |
|---|---|
| Base API address | https://kodrio.com |
| Access key | you issue it in the dashboard, section "Access keys" (/cabinet/api), after company verification (section "Company") |
| The set of scopes of the key | you choose it when issuing (§3) |
| The list of allowed IPs | you set it when issuing; an empty one means no lock (§3) |
Every path below is appended to the base address: https://kodrio.com/v1/catalog. The key is shown
once (§3). ⚠️ Scopes and the address list cannot be checked through a machine "what are my rights" endpoint —
there is no such endpoint in v1: they are visible in the dashboard. In the examples below the address and the key are taken from variables:
BASE=https://kodrio.com; KODRIO_API_KEY=kodrio_live_…The steps in order
Contact with us — the "Support" section of the dashboard (/cabinet/support). Come there from an order screen
and the order number fills into the request by itself. Questions about balance top-ups and about this page go there
too.
Step 1. Get a key. Issue it in the dashboard, section "Access keys", after company verification. Save the key immediately: our database holds only a hash; there is no second way to find out the key. Lost it — issue a new one and revoke the old one in the dashboard.
Step 2. Check that the key is alive. (Below is the balance endpoint; if your key was issued without
balance:read, check GET /v1/catalog. A 403 forbidden_scope answer also means that the
key is ALIVE — it just has no right to this particular endpoint.)
curl -sS "$BASE/v1/balance" \
-H "Authorization: Bearer $KODRIO_API_KEY"{ "data": { "currency_code": "USD", "available_minor": 150000, "overdraft_limit_minor": 0 } }Account money is integers in minor units of your company's currency (currency_code).
For USD these are cents: 150000 = $1,500.00. Catalogue prices are not in it: price, market_price
and the tiers are quoted in the settlement currency of the environment, in dollar cents, for any currency_code (§4).
🔴 Since 2026-08-31 the accounting currency of the environment is the dollar (USD). Before that date accounts were kept in roubles, and
*_minor meant kopecks. If your currency_code is still RUB — your account was not converted, and
the account money (available_minor, overdraft_limit_minor, amount_minor, threshold_minor) remains
kopecks for you: read the unit of account money from currency_code in the response, not from an
assumption. Prices (price, market_price, *unit_price_minor) are in dollar cents for such an account too
— the units will diverge (§4). The exchange rate between top-up and spending is gone: a USDT top-up is credited one to
one to the dollar.
Step 3. Get the catalogue.
curl -sS "$BASE/v1/catalog?brand=STEAM" \
-H "Authorization: Bearer $KODRIO_API_KEY"{
"data": [
{
"sku": "STEAM-TR-100TRY",
"brand": "STEAM",
"region": "TR",
"region_strict": true,
"denomination": 100,
"currency": "TRY",
"delivery_kind": "key_code",
"min_batch": 1,
"code_formats": [
{ "mask": "XXXXXXXXXXXXXXXX", "alphabet": "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" },
{ "mask": "XXXXXXXXXXXXXXXX", "alphabet": "0123456789", "prefix": "ALT-" }
],
"code_format": { "mask": "XXXXXXXXXXXXXXXX", "alphabet": "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" },
"title_ru": "Steam Турция 100 TRY",
"available": true,
"price": 31900,
"market_price": 39900
}
],
"meta": { "next_cursor": null }
}How to tell the catalogue has been read to the end: next_cursor: null. A non-empty cursor comes only on a
FULL page (100 items) — an incomplete page is always the last one.
Step 4. Subscribe to events — §6. Not mandatory, but useful: a webhook brings the outcome by itself, and you do not need to poll the order status in a loop.
Step 5. Place an order — the contract is in §5. Order intake is not open yet (§10.1).
🔴 How long an order waits in the queue — count by two numbers, not by one. An order that the
supplier did not fulfil on the first try goes into the queue (processing + status_detail: "queued",
§5.3), and we repeat the attempts ourselves. An attempt is SCHEDULED about 30 seconds later (spread ±20 %),
but what is scheduled is picked up by a background loop with a step of 5 minutes. So the upper estimate of
waiting for one attempt is about 5.5 minutes, and this is normal operation, not a hung order. After that the pause doubles
(30 s → 1 min → 2 min …) up to a ceiling of an hour, but while it is shorter than the loop step, the pace is set by the step.
Build the waiting budget in your client from 5.5 minutes per attempt, not from 30 seconds — otherwise it will
declare failed an order that is simply waiting for the nearest tick. Attempts are not endless: after
a day or after 24 attempts, whichever comes first, the order becomes failed with a full refund (§5.3).
Step 6. Collect the codes — GET /v1/orders/{id} (§5.5). This is the last step: the code is in hand.
2. Foundations of the contract
Seven rules that apply to every endpoint. If something in the description of a particular endpoint contradicts this section, what is written here is correct.
/v1 in the path from day one. A breaking change is a new version, not an edit of
v1.A single envelope. Success —
{"data": ...}, next to it there may bemetawith service data of the response:next_cursorfor pagination,unknown_skusforGET /v1/stock. Parsemetanot only in paginating code. Error —{"error": {"code", "message", "request_id"}}.Pagination is cursor-only —
?cursor=, the next cursor is inmeta.next_cursor.nullinnext_cursormeans "there are no more pages". Neitherpagenoroffsetis supported.Money is integers in minor units (
*_minor), and there are two classes of it. Account money (available_minor,overdraft_limit_minor,amount_minor,threshold_minor) — in the currency of your company (currency_code); it is taken from the profile and is not passed in the request. One company — one currency. Prices (price,market_priceand all*unit_price_minor— tiers, order, price list file, theprice-changedrefusal) — in dollar cents, the settlement currency of the environment, for anycurrency_code. For an account not inUSDthese two classes diverge — §4.Idempotency is mandatory when creating an order — §5.2.
Availability is returned as a boolean:
available: true|false, with no remainder and no "only a few left". How many exactly you can take, you learn from anot-enough-stockrefusal on a specific order.A missing field is not null. Optional fields (
market_price,closed_for_sale,order_idin the ledger) appear only when the answer is "yes". Neither zero nornullcomes in their place: both would read as a statement we did not make.
3. Authentication
Header and key format
Authorization: Bearer kodrio_live_<40 hex>The scheme is parsed strictly: the word Bearer, one space, the key. Key format —
kodrio_live_ plus 40 lowercase hexadecimal characters. Case
matters: a key converted to upper case somewhere answers 401 with no explanation.
The key is shown once, when it is issued. Rotation is a procedure: issue a new one, revoke the old one; both actions are in the dashboard, section "Access keys". Revocation is instant; we do not cache the decision on a key.
🔴 How to store it. The key is a bearer instrument for your company's money: whoever holds it spends your
balance. Keep it in a secrets manager or in environment variables — not in a repository, not in a
ticket, not in correspondence, not in logs. In the examples below it is substituted from an environment variable
on purpose: a key typed on the command line stays in the shell history and in CI logs.
Do not log the event body or the X-Kodrio-Signature header either.
Orders and subscriptions belong to the company, not to the key. Revoking one key interrupts neither event delivery nor access to earlier orders from your other keys.
🔴 The flip side, and it matters more than convenience: subscriptions SURVIVE key revocation. If you
revoke a key because of a suspected compromise, revocation alone is NOT ENOUGH: a subscription
created by someone else's hands will keep sending your balance and all your orders to someone else's address.
Be sure to open GET /v1/webhooks and revoke everything you did not create (§13).
Scopes
A key has a set of rights; an endpoint requires its own. No right — 403 forbidden_scope.
| Scope | What it opens |
|---|---|
catalog:read | GET /v1/catalog, GET /v1/catalog/export, GET /v1/stock, GET /v1/recipients/fields |
balance:read | GET /v1/balance, GET /v1/balance/entries |
orders:create | POST /v1/orders |
orders:read | reading orders (endpoint — §5.5) |
webhooks:manage | the subscription registry /v1/webhooks* |
🔴 Subscribing to an event also requires the right to read its data. The event body carries exactly the same
as the read endpoints, only it travels to an address you chose. That is why POST /v1/webhooks checks
events[] against the key's rights:
| Event | Additionally requires |
|---|---|
order.delivered, order.failed, order.refunded | orders:read |
balance.low | balance:read |
If something is missing — 403 forbidden_scope, and the reason field lists the names of the missing scopes
separated by commas. A "webhooks only" key cannot subscribe to the order stream.
Allowed address list
Each key has its own IP list. An empty list = no lock, requests are accepted from any address.
A filled one = requests are accepted only from there, the rest get 403 ip_not_allowed.
🔴 Fill in this list. An empty one means that a key that fell into the wrong hands works from anywhere. A filled one is the only thing that tells your request apart from someone else's with the same key. Set your outgoing addresses when issuing the key in the dashboard; the list of an active key cannot be edited — if you need a different one, issue a new key with the needed list and revoke the previous one.
How an address is recorded. On recording we bring it to canonical form (::ffff:1.2.3.4
is stored as 1.2.3.4, IPv6 is compressed and goes to lower case), duplicates are collapsed.
Comparison at the entrance uses the same canonical form, so different notations of ONE address
match: ::ffff:1.2.3.4 and 1.2.3.4 are one address, 2001:0DB8::0001 and 2001:db8::1 too.
Case does not matter. Normalising the form does not widen the list: a neighbouring address of the same network is
a different address, and we do not support masks (see below).
If you still get 403 ip_not_allowed with a knowingly correct address — do not ask to clear the
list: it alone tells your request apart from someone else's with the same key. Tell us the notation,
we will look into it on our side.
What the list does not accept — the refusal comes at once, with a reason code, not silently:
CIDR masks (
10.0.0.0/8) — not supported in this version, list addresses one by one;a string that is not an IP address;
more than 50 addresses in the list.
We refuse on purpose: a list accepted silently and matching nothing would look like a configured lock that lets no one in.
What access refusals mean
| Response | What happened | What to do |
|---|---|---|
401 unauthorized | the key was not accepted | check the header and the key itself; do not guess |
403 forbidden_scope | the key has no right to this operation | issue a key with the needed scope in the dashboard |
403 ip_not_allowed | the request came from an address outside the list allowed for the key | see the warning below |
🔴 An 403 ip_not_allowed you did not expect is first of all a signal of a KEY LEAK, not of a wrong setting. It means the key was presented from someone else's address. If you did not change your outgoing network — assume that someone else is using the key, and act according to §13 "Suspected key leak". Widening the address list in this situation is the worst thing possible: with your own hands you will open access to whoever took the key.
401 deliberately does not distinguish "no such key" from "the key exists but something is wrong with it": from the difference
in responses the very fact that a key exists could be guessed.
4. Catalogue, availability, balance
GET /v1/catalog — the product shelf
Scope catalog:read. Filters ?brand=, ?region= (case does not matter), pagination ?cursor=,
a page is 100 items.
🔴 The endpoint has no other parameters, and the page size is not configurable. ?limit=, ?page=,
?offset= are not supported: we silently ignore unknown query parameters, and the answer comes as
an ordinary page of 100 items. Checking this by the response is pointless — ?limit=2 returns the same 100
rows and 200, not an error. The catalogue is read to the end only by the cursor: a non-empty meta.next_cursor
comes on a FULL page, null means "read to the end".
Row fields:
| Field | Meaning |
|---|---|
sku | the SKU, your key to everything else |
brand, region | brand and activation region |
region_strict | the region is strict (the code does not activate in another one) |
region_countries | the countries where the code activates — a list of ISO 3166-1 alpha-2 codes, only where it exists; explained below |
denomination, currency | the denomination and its currency — the currency of the denomination, not of settlement |
delivery_kind | the delivery kind — key_code · game_key · topup_by_id · topup_by_login · gift_link; ordering supports all of them except gift_link (§10.5) |
min_batch | the minimum batch |
code_formats | code formats — validate the issued code on your side against it; only for items issued as a code, and only where the format is declared; explained below |
code_format | ⚠️ deprecated, removed on 2026-11-18 — the format of the ITEM without regard to who prints it; comes and goes as a PAIR with code_formats; explained below |
title_ru | the name |
available | whether the minimum batch can be ordered right now — see the warning below |
price | may be absent — for an item whose price is not defined on our side right now (an order for it gets 422 price-changed). Our wholesale price per unit, in minor units of the settlement currency of the environment — dollar cents for any currency_code of your account (explained below) — when ordering below the first tier; the same unit as market_price |
price_tiers | wholesale tiers "volume → price", only where they exist — see below |
market_price | the retail market anchor, only where it exists — minor units of the same settlement currency as price |
closed_for_sale | true — the item is closed for sale, only when "yes" |
🔴 price and market_price are IN ONE CURRENCY (changed 2026-09-02, breaking). So
market_price − price is legitimate arithmetic, and that is exactly what changed: before 2026-09-02 the anchor
arrived in rouble kopecks next to cents, and the gap between the units was about 86×.
What that currency is — we say it plainly. Both catalogue prices are quoted in the settlement currency of the environment, and
today that is the dollar: price and market_price are cents. The currency of your BALANCE lives in a separate field,
currency_code (GET /v1/balance, §1); since 2026-08-31 the environment default is USD, and for all accounts
opened since that date the two coincide. If your company's currency_code has remained
RUB (the 2026-08-31 transition kept such accounts and did not convert them), write to us through the channel from §1
before you calculate a markup: the unit of your balance and the unit of catalogue prices will diverge for you, and
diverge silently. The catalogue response itself does not name the currency yet — that is our debt, not your concern. An order on
such an account will not go through: POST /v1/orders will answer 403 currency_not_supported before any debit (§8).
Where the anchor comes from. It is measured in Russian retail — it is the price at which the same card is sold to the end buyer in our market — and it is given out converted into the settlement currency at the same exchange-rate point at which price was calculated. We have no second conversion formula, and this is not a minor detail of the design: while the rate is frozen, both numbers freeze together, so the ratio between them stays correct even when each of them lags slightly.
⚠️ If you calculated a markup from the old numbers — recalculate. In the example from §1 "price": 31900
is $319.00, and "market_price": 39900 is $399.00; both numbers are synthetic, but they are now labelled
in one currency, and the difference between them makes sense.
There may be no anchor: where there is no retail measurement or it is not fresh, the market_price field is
absent from the row altogether — not null and not 0 (rule 7 of §2). It does not become a field either when there is
nothing to convert the measurement with.
Region: region_strict and region_countries
🔴 There are products whose restriction is given as a LIST of countries, not one — these are game keys.
Their region_countries field lists the countries where the code activates, as ISO 3166-1 alpha-2 codes.
The field is absent altogether where no such list exists (gift cards) — as with market_price,
an absent field is not null and not "nowhere".
Reading this field pays off, but it is not mandatory. region is ALWAYS included in region_countries.
If you sell only in region, you land in a country where the key definitely works; if you read the list, you
sell into all the countries of the list. So not knowing the new field costs you orders you did not sell,
not a dead code at the buyer's end.
🔴 Order acceptance judges by the same list. The deliver_region field of an order line must be in
region_countries, otherwise the line is rejected with the code region-mismatch. What is shown to you and
what we check is one and the same field.
{
"sku": "ABYSSUS-RU-VAR-BASE",
"brand": "ABYSSUS",
"region": "RU",
"region_strict": true,
"region_countries": ["AM", "AZ", "BY", "KG", "KZ", "MD", "RU", "TJ", "TM", "UA", "UZ"],
"denomination": "VAR",
"currency": "USD",
"delivery_kind": "key_code",
"min_batch": 1,
"title_ru": "Abyssus, ключ Steam",
"available": true
}🪤 denomination: "VAR" here means "the product has no denomination as such" — a game key never
has one. In the price list file (/v1/catalog/export) the list of countries comes as the LAST column,
region_countries, codes separated by spaces; for tier rows the cell is empty.
Code format: code_formats (and the deprecated code_format)
🔴 BOTH FIELDS ARE ABSENT FOR AN ITEM ISSUED WITHOUT A CODE (since 2026-08-28 these are topup_by_login and
topup_by_id). No code exists for it: the supplier credits funds directly to the account, and you
receive a confirmation of the credit. Declaring a format for such a product would mean giving you a promise
that §9 tells you to CHECK — and you would reject a correct confirmation after payment.
The most reliable way to tell such items apart is by delivery_kind. 🪤 If your client reads
row.code_format.mask without checking that it is present — add the check; for items with a code the field is in place and
has lost nothing.
🔴 BOTH FIELDS MAY ALSO BE ABSENT FOR AN ITEM WITH A CODE — if we do not declare the format of its code (since 2026-09-03). This happens when the item is printed by a supplier whose code shape we have not measured yet: declaring a mask at random would mean giving you a promise that §9 tells you to CHECK — and you would reject a correct paid code. Silence here is the only truthful answer, and it is temporary: as soon as the shape is measured, both fields come back in the same catalogue row.
What this means for your client, in one sentence: code_formats is absent ⇒ there is no need and no means
to check the issued code by format — accept it as it is. The fields come and go as a
pair; "one is there, the other is not" cannot happen by construction. Tell "there will be no code at all" from "there
will be a code, the format is not declared" by delivery_kind, as before.
🔴 code_formats is an array, and the issued code must be checked against it (§9). The issued code must pass at least one format from the array; if it passes none, that is a defect on our side, and we want to know about it (§13).
Why an array and not a single object. A code format is a property of the "item × supplier" pair, not of one item. Each supplier has its own printing press: the mask, the alphabet and the prefix differ between them, and one and the same SKU bought from different suppliers comes to you in a different shape. Who exactly will print your code is decided at the moment of the order — by the availability of the line — so naming one format in advance is physically impossible. The array lists all the formats in which the item can be printed; there are no extra values in it.
⚠️ code_format (singular) was declared deprecated on 2026-08-20 and is removed on 2026-11-18
(§11, 90 days). It describes the format of the ITEM and is therefore truthful only when the printing
supplier changes nothing in it: it will reject a code from another supplier even though the code is valid and
paid for. The field keeps coming for the whole transition period.
The transition is one line: instead of "the code passes code_format" check "the code passes any of
code_formats".
The fields of every format (both in code_formats and in code_format) are the same:
| Field | Req. | Meaning |
|---|---|---|
mask | yes | the shape of the code: X is a character slot, everything else (-, /, a space) is a separator as is |
alphabet | yes | the list of characters allowed in a slot, as a string — for example ABCDEFGHJKLMNPQRSTUVWXYZ23456789. It is the set itself, not its name: take the characters from it literally |
prefix | no | the literal beginning of the code, NOT part of the mask |
suffix | no | the literal tail of the code, not part of the mask either |
example | no | a sample code for a human. It is never a value, but it does pass the declared format |
The full check is prefix + the mask with every X replaced by any character from alphabet, +
suffix. None of the fields is ever an empty string: an optional field is simply absent from the response.
⚠️ If you build a regular expression — escape the substituted values: today the set holds only letters and
digits, but we have not promised that "there will be no character with a meaning in a regex here".
"code_formats": [
{
"mask": "XXXXXXXXXXXXXXXX",
"alphabet": "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
},
{
"mask": "XXXXXXXXXXXXXXXX",
"alphabet": "0123456789",
"prefix": "ALT-"
}
]A code of the first format is exactly 16 characters, one per mask slot. The second is matched by, for example,
ALT-9302847715630284: 20 characters — a 4-character prefix plus 16 slots. Counting the
length by one mask is wrong — it is exactly on this discrepancy that the first home-made check breaks, and
counting it by ONE format from the array is even more wrong.
🔴 prefix is the supplier's press prefix, and it differs between formats. Part of the pair's format:
one supplier has none at all, another has one. Therefore in the live catalogue different elements of
code_formats come with a different prefix, and some of them have the field. Do not write a validator
that drops prefix "because we never had one": the neighbouring format of the same
SKU will have it.
Hence the rule: take prefix from the response in full, cut nothing off and add nothing yourself.
⚠️ Letter literals live in prefix/suffix, not in the mask, and this is not decoration: the letter X
inside a prefix would otherwise become a character slot. The mask consists only of X and separators.
Formats change incomparably less often than prices — there is no need to re-read them before every reconciliation. It is enough to refresh them together with the catalogue.
Wholesale tiers price_tiers
The unit price depends on the quantity in one order line. The tiers come as an array, in ascending order of quantity; there are two fields, and both are mandatory:
{
"sku": "APPLE-DE-10EUR",
"price": 139300,
"price_tiers": [
{ "min_qty": 5, "unit_price_minor": 137200 },
{ "min_qty": 10, "unit_price_minor": 133000 }
]
}It reads like this: 1–4 units — price, 5–9 — 137,200, from 10 — 133,000. The tiers start from the second
unit: the price for a single unit is set by price and by it alone.
🔴 unit_price_minor in an order must be the price of THE tier your quantity
falls into. If you send the price of another tier, the line will be rejected with the code price-changed before money
is debited, and the refusal will carry expected_unit_price_minor with our number (§5.1). There is no separate
error code for tiers: for us it is an ordinary price mismatch.
⚠️ The price_tiers field is absent altogether when the item has no tiers — an empty array never comes. The absence of the field does not mean "volume prices never happen": a grid may appear later.
⚠️ We show only reachable tiers. A tier above the current ceiling on quantity in a line (today 50 units, §5.1) does not get into the response: it cannot be ordered anyway, and we will not print a price at which we ourselves would refuse. When the ceiling grows, the tiers will appear by themselves.
We do not give out a percentage discount: the response carries only the price — the very number you echo back to confirm the order.
GET /v1/catalog/export — the price list as a file
Scope catalog:read. The same catalogue, only as a file: CSV, UTF-8 without BOM, comma as the separator,
CRLF line breaks (RFC 4180). The filters ?brand=, ?region= work the same way as for the catalogue;
there is no pagination — the price list is given out whole, because it is a document, not a feed.
One row of the file = one price, not one item. The base price comes as a row with min_qty=1,
each tier as its own row. The columns, in this order:
sku,brand,region,region_strict,denomination,currency,delivery_kind,min_batch,
title_ru,available,closed_for_sale,min_qty,unit_price_minor,market_price,region_countriesmarket_price is filled in only on the min_qty=1 row: the market anchor is a retail price per unit and has nothing
to do with volume. Its unit is the same as that of unit_price_minor — minor units of the settlement
currency (§4 above). An item without a price stays in the file with an empty unit_price_minor cell
— do not purge your reference list by this sign.
⚠️ There are no code formats in the file in any form — neither code_formats nor the deprecated
code_format. These are nested objects (mask, alphabet, prefix, suffix, example), and a flat table
cannot express them without five extra columns for every format of every row. Take the formats from
GET /v1/catalog — they change incomparably less often than prices. Everything else that is in the catalogue is in
the file.
🔴 The numbers in the file and in GET /v1/catalog match down to the minor unit, because they are the same numbers:
the file is printed from the same catalogue snapshot, recalculating nothing. You can make sure of it with the
X-Kodrio-Snapshot header — it comes with both the catalogue and the price list and names the moment the snapshot was built.
If it matches in both responses, you are looking at one and the same shelf; if it differs, we updated the snapshot
between your requests — take both again.
What X-Kodrio-Snapshot holds: the moment the snapshot was built, as a time in ISO-8601 UTC, with
milliseconds — 2026-08-12T09:12:44.117Z. It can be parsed as a time, but compare it
as a string for equality: the header answers the question "is this one and the same shelf", not "which one is
fresher". The age of a price is measured not by it but by the promises above (10 and 20 minutes). The value will not become
opaque silently: a change of format is a breaking change under §11.
⚠️ If you open it in Excel — import it as UTF-8 ("Data → From Text/CSV → encoding UTF-8"), not with a double click: we do not add a BOM, because it breaks straightforward parsers, and the file is above all for machines.
🔴 available: false and closed_for_sale: true are different things, and your decision on them is different.
available: false means "cannot be ordered right now" (sold out, the minimum batch does not fit).
closed_for_sale: true means "we do not procure this": the item has no live supply channel. The first is
a reason to wait, the second a reason to take the card down on your side. Merging them into one would force you
to wait for years for a product that will not come.
🔴 available: true is a promise of min_batch, NOT of any quantity. It means: there is a price,
we can handle the delivery kind, and the stock is enough for the minimum batch. An order for a larger quantity
may honestly be rejected with the code not-enough-stock (§5.1) — this is normal operation, not a discrepancy with
the catalogue: we do not give out stock numbers (§2 item 6) ANYWHERE, including in the text of a refusal. The practical
way to find a quantity that goes through is to reduce the quantity, not to count on learning the stock from us.
The catalogue is served from a cache: the snapshot is refreshed once every 10 minutes, and if the next rebuild
fails, you are honestly given the previous one, but the price in it is never older than 20 minutes. Beyond that
threshold we do not show the price under any circumstances: instead of it 500 comes (§5.4). So
the data may lag behind our shelf by a third of an hour; plan the frequency of re-reading
for that period.
Both numbers are a CEILING, not a setting we can raise: our configuration can only shorten these windows, not lengthen them. The "age of a price" is counted honestly — from the moment we last CHECKED the rate, not from the moment we rebuilt the shelf on an already known rate. This check stands on every response, not only on one that came from memory: we can rebuild the shelf on a frozen rate as many times as we like, and the price will not become any fresher from that.
The other side is named honestly: while we have nothing to check the rate with, the catalogue answers 500 rather than
showing an old price. In a rare case — if our side has just restarted and the rate has not
been checked even once — this may happen immediately, not after 20 minutes. Your client
must survive a 500 by repeating the request (§8); an order must not be considered failed because of it.
🔴 But we removed one lag: an item closing because a supply channel was lost reaches you
INSTANTLY. If a supplier stopped letting us in or the item's binding was removed, it gets
closed_for_sale: true on your very first request, without waiting for the snapshot to refresh. It used to
take up to twenty minutes, and all that time you could send us an order that we would not have been able
to fulfil. The reverse is also true: an item that comes back opens just as fast — but only while everything is fine
on our side: if we have an outage at that moment, the catalogue answers 500 (§8), and you will see the item come back
after our side recovers.
⚠️ What arrives instantly is precisely the loss of a channel. An item can also close for a third reason — our data on it has simply gone stale by the calendar; such a closure arrives together with the ordinary snapshot refresh, that is, within the same 20 minutes.
available: false does not mean "the item has been deleted" — do not purge your reference list
by this sign.
GET /v1/stock?skus=A,B,C — availability in a batch
Scope catalog:read. The cap is 100 SKUs per request, more → 400 validation_failed.
{
"data": [ { "sku": "STEAM-TR-100TRY", "available": true } ],
"meta": { "unknown_skus": ["STEAM-TR-999TRY"] }
}The case of SKUs does not matter — we upper-case them ourselves, duplicates in the request collapse (the cap of 100
is counted after that). But in the response sku comes in OUR canonical form, in capitals, and
the order of the response rows does not correspond to the request: match by the sku field, not by position in
the array.
🔴 An unknown SKU does not answer available: false — it goes into meta.unknown_skus.
"Out of stock" speaks about our product; about a SKU we do not know, we assert
nothing. Otherwise a typo in your reference list would look like a product that is forever out of stock, which
you would keep waiting for.
GET /v1/recipients/fields?sku= — what the credit for an item is addressed by
Scope catalog:read. It answers the question "what to put in the order line for this item" — before
you order it, and without spending a single rouble.
{ "data": { "sku": "8BALLPOOL-GLOBAL-VAR-110CASH", "forma": "polya",
"polya": [ { "key": "user_id", "type": "text", "secret": false },
{ "key": "platform", "type": "select", "secret": false, "options": ["ios", "android"] } ] } }forma | What it means | What to put in the order line |
|---|---|---|
net | the item has no recipient by design (a code or a link) | neither recipient nor recipient_fields |
stroka | a single detail — a Steam wallet, Telegram stars | recipient |
polya | named fields of the category, listed in polya[] | recipient_fields with the same keys |
🔴 net is a success, not an error. Asking about any item of your reference list is legitimate, and you will not have to tell "we do not know" from "there is nothing to ask" by a refusal code.
key is the name you must send back in recipient_fields. type is text or
select; for select there is options[] next to it with the allowed values. secret: true means
"the field carries a secret" (password, email): do not display it openly and do not store it.
⚠️ We do not give out field labels — they would come as the supplier's text, and we do not let that out (§2). Draw your own label, falling back on the key name itself.
🔴 This endpoint and order acceptance take the set of fields from one source: "what the endpoint showed" and "what
the order will accept" cannot diverge. The case of the SKU does not matter; in the response sku comes in our
canonical form.
🔴 Could not find out — 503 recipient-unavailable, the refusal is REPEATABLE. The set of fields is known to the
supplier; if it is silent or answers empty, we say "repeat later", not "no fields are needed" and
not "one string": otherwise you would assemble a batch for a form that the order would reject on acceptance.
An unknown SKU is 404 not_found, an empty sku is 400 validation_failed.
⚠️ This is not a recipient check. The endpoint answers "which fields to send", not "is the
value any good": there is no valid here and there cannot be, the answer is the same for everyone and says nothing about
any account. There is still no machine /v1/recipients/check — §10.4.
GET /v1/balance — the remaining balance
Scope balance:read.
{ "data": { "currency_code": "USD", "available_minor": 150000, "overdraft_limit_minor": 0 } }overdraft_limit_minor is how deep you are allowed to go into the negative; 0 by default. A debit deeper than that
will not go through: the order is rejected with insufficient-balance before any effects.
In wave 1 top-ups go not through the API — ask through the channel from §1.
GET /v1/balance/entries?cursor= — the ledger of movements
Scope balance:read. Append-only, newest first, cursor pagination.
{
"data": [
{ "operation": "debit", "amount_minor": 63800, "reason": "заказ pord_01J...",
"order_id": "pord_01J...", "at": "2026-08-07T09:12:44.117Z" }
],
"meta": { "next_cursor": "pbe_01J..." }
}🔴 Take the cursor ONLY from meta.next_cursor. A ledger row has no id of its own in the response
— the field does not exist, and this is not an omission in the example: exactly the five fields above go out. Pass the
meta.next_cursor you received into ?cursor= of the next request; null means "the ledger has been read to the end".
⚠️ The cursor is an opaque value: do not compose it yourself and do not derive it from order_id or at.
An unreadable cursor answers 400 validation_failed, but one composed to be readable IN FORM — for example from
someone else's row — will honestly return the wrong piece of the ledger, and silently.
operation is debit (a debit) or credit (a credit: a top-up and any refund).
order_id is present on movements for an order. A movement without an order (a top-up, a manual
correction) has no such field at all — not null.
🔴 reason is human text, not a code. It is written for a person reading the ledger,
the wording changes without warning, and numbers and identifiers are substituted into it. Do not
parse it with regular expressions and do not compare it as a string. The machine link to the order is the order_id field,
the direction of the movement is the operation field.
A ledger page is 50 rows; the size is not configurable.
5. Orders
⛔ Order intake through /v1 is not open yet — §10.1. The contract below is fixed and will not change; write your client against it.
5.1. Creation
POST /v1/orders, scope orders:create, the Idempotency-Key header is mandatory.
POST /v1/orders
Authorization: Bearer kodrio_live_0000000000000000000000000000000000000000
Idempotency-Key: zakaz-2026-09-21-0001
Content-Type: application/json
{ "lines": [ { "sku": "STEAM-TR-100TRY", "quantity": 2, "unit_price_minor": 31900 } ] }Body rules:
lines— a non-empty array, up to 100 lines. Empty →400 validation_failed.Duplicate sku values in lines[] are forbidden — one line = one SKU. Otherwise the quantity ceiling could be bypassed with a hundred lines of one SKU.
quantity— up to 50 per line (a ceiling from the configuration). Above that →invalid-quantity. ⚠️ The top-up line has its own ceiling, and it equals one. Items that are credited to someone else's account (STEAMTOPUP-*,TGSTARS-*and other forms oftopup_by_login) are accepted only one unit per line: the supplier has no quantity field for them at all. Batching top-ups is separate work, and until it is done an order for two units of such an item will return your money in full (batch-over-cap) rather than deliver half.unit_price_minor— an echo of the price you saw in the catalogue. Mandatory.recipient— the recipient AS A SINGLE STRING (since 2026-08-28). Mandatory for items of the Steam wallet and Telegram stars lines (SKUsSTEAMTOPUP-*,TGSTARS-*), forbidden for items issued as a code. The field is per line: in one order you are free to top up different accounts.recipient_fields— the recipient AS NAMED FIELDS, an object{"key": "value"}(since 2026-08-28). Mandatory for items of the games and services top-up line — these are ALL the other items withtopup_by_idandtopup_by_login— where the set of fields is declared by the supplier's category itself (from one to three). The forms are not interchangeable: the explanation and the way to learn the set of fields — §10.5. Refusals of both forms:recipient-required·recipient-invalid·recipient-unknown— all in the refusals table of §5.1 below.
🔴 The echo never becomes the debit price. The debit always goes at our backend's price;
your number is only checked against it. If they diverge, the order is rejected with price-changed before money, and the
refusal line carries expected_unit_price_minor — our price at the moment of the refusal, so that you do not
have to re-read the catalogue blindly.
⚠️ price-changed has a second cause, and in it this field is ABSENT: if the item's price is
not defined on our side at all right now, the refusal comes with the same code but without expected_unit_price_minor —
putting your own echo there would mean confirming your number as our price. Check that the field
is present rather than relying on it: no field — re-read the catalogue, the item may have gone. The price you read in GET /v1/catalog and the debit
price are one and the same number, not two numbers calculated the same way.
Responses:
| Response | Body | What it means |
|---|---|---|
202 | {"data":{"order_id":"...","status":"accepted"}} | the order has been accepted for processing |
200 | the same response as the first time | a repeat of the same key with the same body; nothing has been debited again |
409 processing | error envelope | the key is taken by an order whose outcome is not confirmed yet. Repeating is safe |
409 idempotency_conflict | error envelope | this key is already taken by an order with different content |
422 rejected | line_rejections[] | the order is rejected; the money is untouched, the key is free |
403 currency_not_supported | error envelope | your company's account is not in dollars (currency_code ≠ USD); the money is untouched, the key is free — §8 |
429 rate_limited | + Retry-After | the rate limit has been exceeded |
400 validation_failed | error envelope | the body does not match the schema or there is no Idempotency-Key |
500 internal | + request_id | our error. Do NOT consider the order failed — §5.4 |
A per-line refusal:
{
"error": {
"code": "rejected",
"message": "Заказ отклонён. Причины — по строкам.",
"request_id": "9d4f...",
"line_rejections": [
{ "sku": "STEAM-TR-100TRY", "code": "price-changed",
"reason": "цена позиции изменилась: в заказе 31900, у нас 32400 (минорных единиц)",
"expected_unit_price_minor": 32400 }
]
}
}A refusal at the level of the whole order (for example, insufficient-balance) comes as a line with sku: "*".
🔴 Branch on code, not on reason. reason is human text with substituted
numbers; its wording changes without warning. code is a closed list from the table below.
Line refusal codes:
| Code | What it means | What to do |
|---|---|---|
unknown-sku | we have no such SKU | check your reference list |
invalid-quantity | the quantity is above the ceiling of 50 | split into orders |
batch-over-cap | does not come in line_rejections: it is an outcome AFTER intake — the batch is larger than the supplier's line accepts (for top-ups the ceiling is 1 unit); the order ends failed with a full refund, the code is in the refund reason | order one at a time |
below-min-batch | less than the minimum batch | top up to min_batch |
not-enough-stock | the stock is not enough for this quantity | reduce the quantity or wait |
invalid-price | the line total is above our ceiling for one line | check the quantity and price against the catalogue |
price-changed | the echo diverged from our price or the item's price is not defined on our side | there is expected_unit_price_minor — repeat with it; no field — re-read the catalogue |
closed-for-sale | the item is closed for sale — there is no live supply channel | take the card down on your side |
supplier-unavailable | procurement for this order is unavailable right now — a temporary state on OUR side | repeat later, do NOT take the card down |
kind-not-supported | the delivery kind is visible in the catalogue, but we have no order adapter for it (today that is gift_link) | §10.5 |
recipient-required | the recipient is not named for a top-up item | add recipient or recipient_fields — which one, the refusal text will say |
recipient-invalid | the recipient does not follow the line's rule · is named in the WRONG form (a string instead of fields or vice versa) · is named for an item issued as a code | fix the order line; the missing fields are listed in reason |
recipient-unknown | the supplier did not confirm such an account | check the login with the buyer |
insufficient-balance | not enough money, taking the overdraft into account | top up |
region-unknown, region-mismatch | unreachable through /v1 — the region is set by the SKU itself, nobody asks you for it | do not write a handler |
The reason of not-enough-stock does not name the remaining stock and will not: stock numbers
do not go out (§2 item 6). The refusal text holds only your own number — how many you ordered.
We give out the exact available quantity neither through the catalogue nor through a refusal.
🔴 The boundary between 400 and 422 is not a matter of taste, and you need to know it in advance. 422 with lines means
"the body is correct, but the order does not go through on the merits". Everything that does not match the SCHEMA is cut off earlier and
answers with a bare 400 validation_failed without line_rejections[] — that is, without an explanation of which
line exactly is to blame (we deliberately do not give the reason out). 400 covers: a non-integer,
zero or negative quantity; a non-integer, zero or negative unit_price_minor;
the absence of any of the three line fields; an empty lines[] or one longer than 100 lines; duplicate sku values;
an sku that is not a string or is longer than 128 characters; a recipient that is not a string or is longer than 100 characters;
a recipient_fields that is not an object (an array, a string, null), or with a non-string value, or with more
than ten fields, or with an empty field name or one longer than 64 characters, or with a value longer than
100 characters; the absence of the Idempotency-Key header, its length above 255 characters, and also an idempotency
key starting with sandbox: — this prefix is reserved by us.
Check these conditions on your side before sending — otherwise debugging goes blind.
🪤 Note: recipient_fields is an object, not an array. The supplier lists the fields
as an array, and repeating its shape is a natural guess; we accept exactly an object,
{"user_id": "123"}, and an array is rejected as 400.
⚠️ Leading and trailing spaces are trimmed from the idempotency key: "ord-1 " and "ord-1" are
ONE AND THE SAME key, not two different orders.
There is no partial delivery in v1: a refusal of any line = a refusal of the whole order. An accepted order is either delivered in full or the money is returned in full.
5.2. Idempotency
Why. Networks break, responses get lost. The key is the only way to tell "a second order" from "the same order, repeated". Without it no safe repeat exists, which is why the header is mandatory rather than optional.
The key is a string of up to 255 characters, a separate one for every order. Your own order number will do, if it is not reused.
A repeat with the same key and the same body returns
200with the previous response. It will not debit a second time.A repeat with the same key and a different body →
409 idempotency_conflict. This is a protection: treating such a request as a new order would mean debiting twice because of your own typo.A key is valid while its row exists, and for no less than 24 hours. 🔴 Do not reuse a key "a day later, it has expired by now" — it does not expire by time. A repeat of an old key with the same body will return the previous order, and you will consider a new one placed.
After 422 rejected the key is free. The refusal is not recorded: you fix the body and repeat with the same key, and the order is evaluated anew.
409 idempotency_conflictis possible only for a key that is already taken by an accepted (202) order.
🔴 "The same body" is judged by lines[], and only by them. Five fields of each line are compared —
sku, quantity, unit_price_minor, recipient and recipient_fields (since 2026-08-28); the order of the lines
and the order of the keys inside recipient_fields do not matter
(a permutation describes the same order). A missing recipient and an empty string are the same thing;
a missing recipient_fields and an empty object — too. 🪤 The recipient is part of the comparison deliberately: the same SKU
in the same quantity at the same price, but to a DIFFERENT login, is a different order and different money to a
different account. If it were not part of the canon, a repeat with a corrected login would return you the response of the first
order, and you would consider the correction accepted. Headers are not part of the body comparison.
5.3. Order statuses
accepted → processing → delivered | failed | refunded🔴 accepted is the status of the RESPONSE to placing an order, not a response of the read endpoint. It comes exactly in the
body of 202 (and of 200 on a repeat) of POST /v1/orders. GET /v1/orders/{id} never returns
it, even if you ask right after 202: from there comes either processing or an already
terminal status (a fast delivery manages to finish before your first read). Treating
accepted as non-terminal is correct; writing a branch "in case it comes from a read" is not needed.
processing is not a refusal. Delivery is asynchronous. An order in
processingmust not be marked failed on your side: only the three statuses on the right are terminal.delivered— delivered.failed— not delivered, the money was returned automatically; the refund is visible as a separate entry in the balance ledger.refunded— a refund after delivery (a code recalled, a claim).An order must reach a terminal status. Stuck ones are finished off by our reconciliation loop: first with a repeated delivery attempt, and only if delivery is impossible —
failedwith a refund.One terminal event comes per order. The only transition allowed after a terminal one is
delivered → refunded.🟢 status_detail with processing (appeared on 2026-08-08). An optional field; when it is present, it explains WHAT is happening to the order: ·
queued— the order is waiting in the delivery queue (the supplier is temporarily not giving out codes, we retry on a schedule); ·stuck— it is waiting longer than usual (over 15 minutes). Neither value is a refusal, and the reaction to both is the same: re-read the status later. There is NO field when there is nothing to explain — the absence of the field does not mean a problem. We do not promise that the list of values will stay at two: new ones are added without deprecation, so read an unfamiliar value as "justprocessing".
5.4. What to do if our response did not arrive
A timeout, a dropped connection or a 500 on POST /v1/orders:
Repeat the request with the same Idempotency-Key. This is always safe — it will not debit a second time.
⚠️ One exception, while order intake is not open (§10.1): right now 500 comes for EVERY
order, and this is a permanent state, not a failure. A repeat does not cure it. The rule below is about a real
outage, that is, about a refusal on part of the requests while the other endpoints work.
🔴 Never create a repeat with a new key without finding out the fate of the old one. A new key is a new order, and you will get two debits for one order of your customer.
The same procedure applies to 409 processing, but with a pause: repeat no more often than once a minute.
To find out the fate of an order whose number you have, you do not need to repeat the order — read §5.5.
A typical order is resolved in seconds; an order stuck because of a drop on our side is finished off by
our reconciliation loop, and this can take up to 20 minutes. A tight loop will not speed up the answer here, but
will burn your quota for creating orders (§7) — and all your integrations will get 429, including
the healthy ones.
🔴 "No more often than once a minute" is about REPEATING POST /v1/orders, and it is a recommendation, not a separate limit. Technically, order creation is limited to 30 requests per minute per company, reading to 120 (§7); a machine "once a minute" does not exist anywhere, neither on writes nor on reads. The pause is named because there is NOTHING to ask about more often: an order that has gone into the queue is moved forward by a background loop with a step of 5 minutes (§1), and the answer will not change before its tick. The pace of reading the status — §5.5.
5.5. Collecting the codes — order status and delivery
GET /v1/orders/{id} · scope orders:read. This is the source of truth about the order: a webhook (§6)
notifies, and this endpoint answers. They cannot diverge — the status in both is computed by one and the same
rule.
curl -sS "$BASE/v1/orders/pord_01K1Z8Q0EXAMPLE" \
-H "Authorization: Bearer $KODRIO_API_KEY"{
"data": {
"order_id": "pord_01K1Z8Q0EXAMPLE",
"status": "delivered",
"items": [
{ "sku": "STEAM-TR-100TRY", "quantity": 2, "codes": ["TEST-ABCDE-FGHIJ", "TEST-KLMNO-PQRST"] }
]
}
}items comes ONLY with delivered. With
processingandfailedthis field is absent altogether — not an empty array, but absent. You would read an empty list as "delivery is finished, zero codes".One item per SKU,
quantityalways equals the length ofcodes— we do not give out a separate counter that could diverge from the array.Statuses — §5.3. Today
processing,deliveredandfailedare reachable;acceptednever comes from this endpoint (§5.3). Next toprocessingthere may bestatus_detail(queued/stuck) — see §5.3.Validate the codes by code_formats from the catalogue (§4), not with a regex "by eye" and not by the deprecated
code_format. A code must pass any one format from the array: it was printed by one of our suppliers, and whose press did it is decided at the moment of the order. A format describes the code IN FULL, together withprefix: if a format has a prefix, it is part of the code. There is no need to cut it off before the check. ⚠️ A check by the singlecode_formatwill reject a valid paid code if the item was printed by a supplier other than the one whose format is in this field. That is exactly the reason for the deprecation (§11).
🔴 You can re-read indefinitely, as many times as you like. There is no "show once" on the API path, and none is planned: the truth about the order lives with us, not in a single response that you might not have received because of a dropped connection. We do not limit the storage period; should such a limit ever appear, it will go through deprecation under §11, that is, no earlier than 90 days after the announcement.
⚠️ 404 not_found means exactly one thing: "you have no such order". We deliberately do not distinguish "does not exist" from "someone else's". If you are sure the order is yours, check the order number and that the key was issued to your company: orders are readable with any of its live keys (§3); a test key does not see live orders (§10.2).
⚠️ processing is neither a refusal nor "lost". That is how an order answers that has been accepted (202) but has not yet
reached a terminal outcome. If status_detail: "queued" or "stuck" stands next to it, the order
is waiting for delivery at the supplier; we retry ourselves, no intervention on your side is needed.
The polling pace of this endpoint. There is NO separate "once a minute" restriction on reading: the general read quota applies — 120 requests per minute per company (§7). Once a minute is our advice, and here is its price: an order in the queue is moved forward by a loop with a step of 5 minutes (§1), so polling once a second will return the same thing five hundred times in a row and burn the quota shared with all your integrations. A reasonable waiting budget is from 5.5 minutes per attempt; more reliable than polling is a webhook (§6), it brings the outcome by itself.
5.6. Going through your orders — the list
GET /v1/orders?cursor=&limit= · scope orders:read, the same as for reading a single order: it is one
right — "re-read your own orders".
curl -sS "$BASE/v1/orders?limit=50" \
-H "Authorization: Bearer $KODRIO_API_KEY"{
"data": [
{
"order_id": "pord_01K1Z8Q0EXAMPLE",
"status": "delivered",
"created_at": "2026-09-01T09:00:00.000Z",
"sandbox": false,
"currency_code": "USD",
"amount_minor": 700,
"lines": [{ "sku": "STEAM-TR-100TRY", "quantity": 2 }]
}
],
"meta": { "next_cursor": "pord_01K1Z8Q0EXAMPLE" }
}Newest first. The order is by order number, descending; the number grows over time, so this is also the chronology.
Cursor pagination (§2.7): pass
meta.next_cursorinto?cursor=— you get the next page.next_cursor: nullmeans there is nothing further. An order placed BETWEEN your requests does not get onto the following pages and does not push anything off them: it gets a larger number and stays on the first one.limit— up to 200, 50 by default.created_at is the moment the order was ACCEPTED, the same one from which we count "stuck" (§5.3), not the moment the outcome was recorded.
status and status_detail — the same values and by the same rule as in GET /v1/orders/{id} (§5.3, §5.5). They cannot diverge: the status in both endpoints is computed by one and the same code.
lines is the composition WITHOUT codes. Codes are given out only by
GET /v1/orders/{id}, one order per request (§5.5), and this is a boundary, not a saving: the list is a traversal, and codes in it would mean that one request carries out all your goods for all time.sandbox — the environment of the order. With your key you see only your own environment (§10.2), so for the machine path this field is constant; it is there because the same body is also shown by the dashboard, and it sees both.
🔴 An order that cost no money IS in this list. This is the main thing that sets the list apart from a summary built from the balance ledger, which we used to advise. A deferred, queued and refunded order either does not get into the ledger of movements at all or gets there as a pair of rows — that is, a summary by money silently lost part of your batches. The axis of this list is the orders themselves, and money comes to them from a third source.
⚠️ currency_code and amount_minor are an optional PAIR. They are absent from every order
for which we cannot name the amount: that is what a batch older than the retention window of service records looks like, for
which money never moved once. This is "there is nothing to name the amount with", not "the order was free" —
which is why the fields are exactly absent rather than coming as zero. lines is absent for the same reason and
means "the composition was not preserved".
⚠️ The list has no ?status= filter — why, and what to do, see §10.3.
6. Webhooks
We send events to your address so that you learn about an outcome without polling us.
🔴 A webhook is a notification, not a source of truth. The truth lives with us, and it is read with the read endpoints. Do not build money decisions on a webhook alone.
6.1. Subscription
POST /v1/webhooks, scope webhooks:manage + the read right for each event (§3).
{ "url": "https://hooks.example.com/kodrio", "events": ["order.delivered", "order.failed"] }The 201 response is the only time you see the signing secret:
{ "data": { "id": "pwh_01J...", "url": "https://hooks.example.com/kodrio",
"events": ["order.delivered","order.failed"], "secret": "<секрет подписи>" } }The secret cannot be recovered: we keep it encrypted. If you lost it or are changing it — read the procedure below, it is not what it seems.
🔴 Rotating the secret without losing events. The procedure is only this:
bring up a SECOND address for the receiver (for example
/kodrio-v2) and create a subscription to it — you will get a new secret;make sure by
last_success_atinGET /v1/webhooksthat events have started going to the new address;only now revoke the old subscription.
⚠️ The reverse order loses events irrecoverably. While there is no active subscription, an event is not
just undelivered — it is not created at all, and there is nothing to raise it with: neither a retry, nor
a replay, nor through us. Rotation "on the same address" is impossible: one active subscription is allowed per
address (409 webhook_duplicate_url), so the old one would have to be removed first.
Requirements for the address (this is an anti-SSRF control, there are no exceptions):
strictly
https://— no exceptions, including loopback;the address must not resolve into a private, loopback, link-local or metadata range;
redirects are not followed —
3xxcounts as a failure and goes into retry.
⚠️ Do not put a secret into the query string of the address either. We store the address whole and return it in
GET /v1/webhooks to any of your keys with the webhooks:manage scope. The authenticity of our request
is proved by the signature (§6.3) — no other secret is needed in the address.
The address is checked both at registration and on every send. Not accepted — 422 url_rejected,
with the reason as a code in the reason field. The set of codes is closed, so that your automation can sort it out without us:
reason | What is wrong with the address |
|---|---|
not-https | the scheme is not https: |
private-address | the address resolves into an internal range |
dns-failed | the name does not resolve at all |
not-a-url | the string cannot be parsed as an address |
too-long | the address is longer than allowed |
credentials-in-url | a login or password inside the address (https://user:pass@…) — they would end up in our delivery log |
The other endpoints of the registry:
| Endpoint | What |
|---|---|
GET /v1/webhooks | the list of your subscriptions (there are no secrets in it) |
DELETE /v1/webhooks/{id} | revoke a subscription → {"data":{"id":"...","revoked":true}} |
POST /v1/webhooks/{id}/replay | replay an event, {"event_id":"..."} → {"data":{"event_id":"...","queued":true}}. Restrictions — right below |
Restrictions: 10 active subscriptions per company (above that → 409 webhook_limit), one active
subscription per address (409 webhook_duplicate_url). Only an event whose delivery
has already finished can be replayed; an event in the queue → 409 replay_not_terminal.
🔴 What you need to know about replay BEFORE you rely on it:
You know the event_id only of events that have reached you. There is no machine list of events and deliveries in
v1— that is, you have no means to replay an event you have NEVER received. If the receiver was down for longer than a day and the events have exhausted the retry window, they can be raised only through us: write through the channel from §1.A replay is one attempt, not a new cycle of retries. The attempt counter and the 24-hour window are counted from the birth of the event, not from the replay. If you did not answer
2xx, the event becomes available for replay again, but it will not repeat by itself.Replay is possible within 30 days of the birth of the event; after that it is deleted together with the delivery log.
{"queued": true}means "put into the queue", NOT "delivered". The fact of delivery is only a2xxfrom your receiver.
What GET /v1/webhooks returns (as fields, so that you do not have to guess):
id · sandbox · url · events[] · secret_prefix · secret_last4 · created_at · revoked_at · last_success_at · consecutive_failures · last_http_status · last_failure_code. There is no secret in the list in any form.
sandbox is a service field; for subscriptions created with a live key it is always false.
🔴 The list also contains REVOKED subscriptions — the live one is the one with revoked_at: null. A check
"a subscription to this address already exists" without this condition will consider the channel set up when it is not.
consecutive_failures, last_http_status and last_failure_code are the health of your receiver;
this is the only machine way to see that it is falling apart.
6.2. Wave 1 events
| Event | data |
|---|---|
order.delivered | order_id, status: "delivered", amount_minor (debited), currency_code |
order.failed | order_id, status: "failed", amount_minor (returned), currency_code |
order.refunded | order_id, status: "refunded", amount_minor, currency_code |
balance.low | currency_code, available_minor, threshold_minor |
The event envelope:
{
"event_id": "pwev_01J...",
"event": "order.delivered",
"created_at": "2026-08-07T09:12:44.117Z",
"data": { "order_id": "pord_01J...", "status": "delivered",
"amount_minor": 63800, "currency_code": "USD" }
}The data of an order event may get a service field sandbox; your events do not have it.
The balance.low threshold is off by default — until it is set, there will be no event. Silence here means "the threshold is not set up", not "there is enough money".
🔴 The threshold cannot be set through /v1 today — there is no machine settings endpoint in v1. Name
the value you need through the channel from §1, and we will set it. Until the threshold is set, a subscription to
balance.low will be accepted (201), but will not send a single event — do not consider it a working
warning about money.
In what units to name the threshold: in minor units, like all money in this contract (§1) —
500000 means 5,000.00 in your company's currency (for a USD account that is $5,000.00), not five hundred
thousand: take the unit from the currency_code of the response, not from an assumption (§1). The threshold
comes back with the same number in the event's threshold_minor field, so you can check that we understood you correctly
with the very first event.
How to choose the value. The threshold is not "little money" but "only just enough time left to top up", so it is calculated from your spending, not from a round sum: take your average spending per day and multiply it by the time your top-up takes. In wave 1 top-ups go through the contact channel (§1), that is, they require a live person on both sides — a day or two of margin is more sensible than an hour. The threshold is checked against the remaining balance after each of your orders — of any outcome, including a refusal for lack of money.
⚠️ The event comes ONCE per downward crossing, not for every order below the threshold. Until
the remaining balance has risen back to the threshold or above, there will be no second balance.low. Once it has risen, the alert silently re-arms on the
next order; we do not send a separate "there is enough money again" event —
that is visible from the balance. Practical consequence: do not take silence as confirmation that there is enough money, and do not
build your only protection against an empty wallet on this event — read GET /v1/balance.
6.3. Signature
The header X-Kodrio-Signature: sha256=<HMAC-SHA256(secret, raw body)>.
Convenience headers ride alongside — X-Kodrio-Event, X-Kodrio-Event-Id, X-Kodrio-Attempt.
🔴 They are not signed. Dedup and any decisions — by the event_id from the body, not from the header.
🔴 The signature contains no time, so dedup on your side is mandatory, and it must be
DURABLE. Otherwise a captured valid request can be replayed indefinitely. Store the processed
event_id values in a table with UNIQUE, not in process memory: a restart of the receiver would reset the dedup exactly
when our retries are coming. In addition, discard events whose created_at is older than
15 minutes.
🔴 Compute the HMAC over the raw bytes of the body, before parsing the JSON. JSON.stringify(JSON.parse(x))
returns a different byte sequence (key order, spaces, escaping, precision of
numbers), and an honest signature will stop matching. This is the most common receiver mistake.
const crypto = require("crypto")
// express.raw, not express.json: you need the untouched body buffer.
// 🔴 express.json() mounted HIGHER in the chain parses the body first — express.raw then
// skips the request, req.body turns out to be an object, and hmac.update(req.body) throws TypeError.
// Symptom: 500 on every event and a day of retries, not "signature mismatch". Mount this
// route BEFORE the global express.json(), or exclude its path from it.
app.post("/kodrio", express.raw({ type: "application/json" }), (req, res) => {
const ozhidaem = "sha256=" + crypto.createHmac("sha256", process.env.KODRIO_WEBHOOK_SECRET)
.update(req.body).digest("hex")
const prislano = String(req.headers["x-kodrio-signature"] || "")
const a = Buffer.from(ozhidaem), b = Buffer.from(prislano)
// Constant-time comparison: a plain `===` tells whoever is guessing the length of the common prefix.
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401)
const sobytie = JSON.parse(req.body.toString("utf8"))
if (uzheObrabotano(sobytie.event_id)) return res.sendStatus(200) // dedup is mandatory, see §6.4
// 🔴 RESPOND FIRST, PROCESS AFTER (requirement of §6.4). Our deadline is 10 seconds of wall-clock
// time: processing inside the request will one day hit it, we will drop the connection and send
// the event again — in parallel with the first processing, which has not finished yet. Dedup will
// not save you here: the first processing has not set its mark yet.
res.sendStatus(200)
vOchered(sobytie)
})6.4. Delivery, retries, order
A 2xx response within 10 seconds = delivered. Everything else (including
3xx) is a failure.Retries with an exponential pause and a spread of ±20 %: the first pause is 30 s, then doubling up to a ceiling of 1 hour. The retry window is 24 hours from the birth of the event; after that the event stays in the log, but will no longer come by itself. It can be raised with a replay only if you know its
event_id, that is, if it has reached you at least once — the replay restrictions are in §6.1.🔴 Delivery is at-least-once, not exactly-once. One event may come twice — dedup by event_id is mandatory on your side.
🔴 The order of delivery is not guaranteed. Order events by
created_atyourselves; do not rely onorder.deliveredarriving before the next event.The delivery log is kept for 30 days.
Answer 2xx immediately and process asynchronously: 10 seconds is a wall-clock deadline, and
a slow receiver arranges retries for itself.
7. Rate limits
Headers come on every successful response and on 429. On access refusals (401, 403)
there are none: the request is rejected before the quota is counted.
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 41X-RateLimit-Reset is the number of SECONDS until the window resets, not a unix timestamp. This differs
from the habit of many APIs, which is why it is said plainly: 41 means "in 41 seconds", not "the year 1970".
If exceeded — 429 rate_limited plus Retry-After, also in seconds. Build your pace on Remaining,
not on 429.
Starting values (not a promise — they are revised, and a change will go as a line in the changelog):
| Category | Endpoints | Limit |
|---|---|---|
| reading | catalogue, price list file, availability, balance, ledger, reading an order GET /v1/orders/{id}, the list of orders GET /v1/orders, subscription registry | 120/min |
| order creation | POST /v1/orders | 30/min |
| event replay | POST /v1/webhooks/{id}/replay | 10/min |
The "reading" category is "everything else". It includes any /v1 endpoint except the two below:
the list in the row describes today's composition, it does not restrict it.
🔴 The quota is shared across all of the company's keys, not per key. One runaway script catches 429
for your other integrations, and issuing additional keys does not increase the quota.
The headers may also be absent. Their absence means "unknown", not "unlimited": keep the pace by the last known value.
⚠️ There is a second 429 — from our infrastructure, not from the quota. It comes without
X-RateLimit-*, with a short Retry-After, and means "too dense a stream FROM YOUR ADDRESS".
It is counted by address, not by company, and that is the whole difference: one integration will not catch it within its quotas,
but several of your integrations (or several of our partners) behind a shared outgoing
address can, especially if they send in bursts. Telling it apart is simple — by the absence of X-RateLimit-Limit;
the reaction is the same, Retry-After. If you catch such a 429 regularly — write to us: we raise the threshold.
8. Errors: the full dictionary
An error always comes in one envelope, message is from a closed dictionary, request_id is always
present. Give us the request_id when you write about a failure.
{ "error": { "code": "forbidden_scope",
"message": "У ключа нет права на эту операцию.",
"request_id": "9d4f2b1e-...", "reason": "orders:read" } }| HTTP | code | When |
|---|---|---|
| 400 | validation_failed | the body does not match the schema, an empty lines[], >100 SKUs in /v1/stock, an unreadable cursor, no Idempotency-Key |
| 401 | unauthorized | the key is missing / wrong / revoked |
| 403 | forbidden_scope | the required scope is missing; for a subscription, reason holds the missing scopes |
| 403 | ip_not_allowed | the address is outside the list allowed for the key |
| 403 | verification_required | the company has not passed verification. A live key is not allowed onto the money path (POST /v1/orders) until the company is verified — the order amount plays no role. The catalogue, stock and balance are read as usual. message says what to do and links to the "Company" section of the dashboard |
| 403 | currency_not_supported | orders on an account not in dollars are not accepted. Catalogue prices and debits are kept in dollar cents; for an account with a currency_code other than USD the units would diverge, so POST /v1/orders is rejected before money, and the idempotency key is free. The catalogue, stock and balance are read as usual. Write to us — we will convert the account |
| 404 | not_found | there is no such object — or it is not yours. We do not confirm that someone else's exists. The same code answers any address under /v1 that we do not have (a typo in the path, an endpoint from a future wave): an unknown address is answered with the same envelope, not a page of markup |
| 409 | idempotency_conflict | the key is taken by an order with a different body |
| 409 | processing | the key is taken, the body is the same, the outcome is not confirmed. Repeating is safe, but no more often than once a minute — resolution takes up to 20 min (§5.4) |
| 409 | webhook_limit | there are already 10 active subscriptions |
| 409 | webhook_duplicate_url | an active subscription to this address already exists |
| 409 | replay_not_terminal | the event is still in the queue, no need to replay |
| 422 | rejected | the order is rejected, the reasons are in line_rejections[] (§5.1) |
| 422 | url_rejected | the subscription address is not accepted, the reason as a code in reason |
| 422 | kind-not-supported | a recipient check does not apply to the delivery kind. Unreachable through /v1 today — the endpoint does not exist yet (§10.4) |
| 422 | recipient-invalid | the recipient is not valid — named not by the line's rule, or the supplier did not confirm it. Fixed by correcting the login in your line; repeating without changes is pointless. Unreachable through /v1 today — the endpoint does not exist yet (§10.4); the per-line refusal with the same name inside an order — §5.1 |
| 503 | recipient-unavailable | the set of recipient fields could not be found out (GET /v1/recipients/fields, §4): the schema is known to the supplier, and it is silent or answers empty. The refusal is REPEATABLE — repeat later without changes. We deliberately do not answer "no fields are needed" or "one string": you would assemble a batch for such a form that the order would reject |
| 429 | rate_limited | rate limit, the pause is in Retry-After |
| 500 | internal | our error. An order, if it was created, is NOT considered failed (§5.4). ⚠️ On POST /v1/orders today this is a PERMANENT state, not an outage — §10.1 |
| 503 | secret_not_configured | we are not set up to issue subscriptions. Repeat later without changes |
| 503 | sandbox_not_available | a key of a retired type — a test key issued before 2026-09-28: it no longer serves the balance, the ledger or orders (§10.2). A repeat will not help — issue a live key in the dashboard (§1) |
🔴 500 and 503 are different responses, and you need to react to them differently. 500 means we broke:
repeat (an order — with the same key) and tell us the request_id through the channel from §1 — except a 500 on a
correct order while intake is not open: that is a permanent state (§10.1). 503 means we are not set up: the request
is correct, repeat later without changes, no need to write, we already know. The exception is sandbox_not_available:
it is cured not by a repeat but by a live key.
9. Code format and delivery
Ordering supports
key_code,game_key,topup_by_idandtopup_by_login;gift_linkhas remained without an order adapter — it is visible in the catalogue and rejected withkind-not-supported(§10.5).🔴 "The delivery kind is supported" ≠ "this item can be bought right now". Always check whether a specific item can be ordered by
availableandclosed_for_sale, not by this list: the item may have no price, no stock or no category open at the supplier.Code formats are described in the catalogue by the
code_formatsfield — validate the delivery on your side against it, not with a regex "by eye". The fields explained and an example of the check — §4 "Code format".🔴 A format is a property of the "item × supplier" PAIR. Each supplier has its own printing press, and who will print your code is decided at the moment of the order. Therefore an array of formats comes, and the issued code must pass at least one of them. The single field
code_formatdescribed the format of the item, was not true of every delivery, and is removed on 2026-11-18 (§11).🔴 alphabet is the list of allowed characters as a string (
ABCDEFGHJKLMNPQRSTUVWXYZ23456789), from which you build a character class. Not the name of a set and not a range in regex notation: substitute it into the check literally.🔴 A code is checked IN FULL, together with prefix and suffix, not by the mask alone: the code is longer than the mask by exactly them — there is no need to remove them before the check, and you should not (§4).
🔴 A code that has passed NONE of the declared formats will not reach you. On our side delivery is checked against the format of the pair that printed it, and a mismatch closes the order: the money is returned, the code does not go to the partner. The promise of §1, "we do not accept an order that cannot be fulfilled", extends to the shape of the issued code as well.
🔴 Codes are given out by GET /v1/orders/{id} (§5.5), and only by it. Neither the order response nor the
order.deliveredevent carries codes, and they will not: the order response hasorder_idand the status, the event hasorder_id, the status and the amount. The reason is not technical: the event goes to your address over a network we do not control, and a code is goods. You need to come for it with your own key.The storage period is not limited. What has been issued can be re-read indefinitely and as many times as you like; there is no "show once" on the API path. Should a limit ever appear, it will go through deprecation under §11 (no earlier than 90 days after the announcement). It is still worth saving the codes on your side: they are your goods, and there is no reason to depend on someone else's availability at the moment of delivery to the buyer.
10. What v1 does not promise
An honest list is cheaper than a dispute. Everything here is either not built yet or deliberately
not part of v1. When something appears, it will go as a line in the changelog.
10.1. Order intake is not open yet
The §5 contract is fixed and will not change.
🔴 What this looks like in the response, and why it is an exception to §8. While live intake is not open,
a correct order with a live key gets 500 internal — and this is a PERMANENT state, not an outage
on our side.
So the usual advice about 500 (§5.4 and §8) does NOT apply to it: repeating such an order
is pointless — the answer will change neither in a second nor tomorrow — and there is no need to tell us about it,
we know. Telling one from the other is simple: a correct order — the SKUs exist, the price
matches the catalogue, the quantity is within the ceiling — gets 500 ALWAYS today. If the
response is 422, this is not it: sort it out by line_rejections[], the matter never reached the lock.
Your money is untouched in every scenario — the refusal happens before the debit, and the idempotency key stays free.
The opening of intake will be a separate line in the changelog.
10.2. There is no test environment
v1 has no test environment — neither a separate address nor test keys. The first order goes with a live
key as soon as intake is open (§10.1). A test key issued before 2026-09-28 reads the catalogue, but the code
formats come to it with the test-environment prefix that live codes do not have; it creates a subscription, but no events will
come to it; it does not see live orders, and on the balance, the ledger and an order it gets 503 sandbox_not_available
(§8). Move your whole integration, catalogue and webhooks included, to a live key.
10.3. The order list has no status filter
The list itself was opened on 2026-09-07 (§5.6) — what it does NOT have yet is the ?status= parameter.
The reason is not the task queue but the design: we do not store the order status as a separate field, it is computed at the moment of the response from the delivery log, the acceptance marker and the push-through queue. There is nothing to select by it in a query, and selecting from an already assembled page would make the pagination lie: you would get three rows out of fifty together with a cursor pointing to the fiftieth, and would decide that you have exactly three batches "in progress". The filter comes together with the order state machine; until then it is more honest not to have it than to have it broken.
The practical workaround for today: the page gives out the status of every row, select on your side
— the order and the cursor stay correct in the process.
10.4. There is no recipient check /v1/recipients/check yet
The endpoint is needed by the "top-up by login/ID" delivery kinds. Since 2026-08-28 top-up by login can be ordered, and
we check the recipient OURSELVES — among the order predicates, BEFORE the debit (§5.1): a typo is rejected
with the code recipient-unknown rather than turning into money on someone else's account. A separate machine endpoint
for a preliminary check comes in its own wave; until then the safe procedure is to send the order and
read the refusal, which is free and comes before money.
⚠️ Do not confuse it with GET /v1/recipients/fields (§4), which arrived on 2026-08-31. That one answers
"which fields to send", this one "is the value any good"; the set of fields is known, and the validity of the account on
the machine path is still judged by the order itself.
For key_code no recipient exists by design, and such a request answers
422 kind-not-supported.
10.5. What can be ordered by delivery kind
Ordering supports key_code, game_key, topup_by_id, topup_by_login. Without an order adapter
gift_link remains: it is visible in the catalogue and rejected with kind-not-supported. We show such
items honestly rather than hide them — the assortment is real.
🔴 The list answers "we know how to order this", NOT "this is on sale today". Whether an
item can be ordered is available and closed_for_sale, and it is decided by other things: is there a price, is there
stock, is the category open at the supplier, is the line switched on. An item of a supported kind may well
come with closed_for_sale: true.
🔴 Items WITHOUT a code require the recipient's details, and they DIFFER between lines (§5.1):
🔴 The form of the details is set by the LINE, not by delivery_kind — and this matters, because one and the same delivery form occurs in both lines:
| Line | How to recognise it | Details in the order line |
|---|---|---|
| Top-up of a Steam wallet and Telegram stars | SKUs STEAMTOPUP-* and TGSTARS-* (there are nine of them today) | recipient — a single string (a login / @username) |
| Top-up of games and services (everything else) | any other SKU with topup_by_id or topup_by_login | recipient_fields — the NAMED fields of the category |
⚠️ Do not judge by delivery_kind. There are 321 topup_by_login items today, and only nine of them are
Steam and Telegram; the other 312 ("Auto Via Login") belong to the services top-up line and require
recipient_fields (email, password, server). There is no separate field marking the line in the catalogue yet
— if in doubt, send recipient_fields: the line that needs a single string
is listed above by name and is short.
In the services top-up line the supplier asks each category for its own set — from one field
(user_id) to three (email, password, server) — so a single recipient does not suit it
even when there is only one field: substituting a string "into the first field" would mean guessing,
and the guess will become wrong on the day the category gets a second one. Such a string is rejected with
recipient-invalid, and vice versa — recipient_fields for Steam/Telegram is rejected with the same code.
⚠️ How to learn the fields you need. Ask — GET /v1/recipients/fields?sku= (§4) gives out the set of
fields as a structure, with the catalog:read scope. The second way remains and costs nothing: send a line with
any set, the refusal will come in line_rejections[].reason, list the missing fields by name, and
for a field with a closed list name the allowed values — and it comes before the debit.
🔴 Items without a code have NO code_format / code_formats fields AT ALL, and this is an answer, not an omission: no code exists, the supplier credits funds directly to the account, and you receive a confirmation of the credit. We check the recipient BEFORE the debit.
🔴 game_key is not a synonym of key_code, and you should tell them apart. In both cases you receive
a code, but a game key has its own regional restriction: it activates in a LIST of countries
(region_countries), not in one. It never has a recipient — recipient and recipient_fields
in such a line are rejected with recipient-invalid.
10.6. Other things deliberately not in v1
Availability events (
stock.out/stock.back) — availability is read from the catalogue and from thenot-enough-stockrefusal.Partial delivery — a refusal of any line = a refusal of the whole order with a full refund.
Batches above the ceiling (
quantity> 50 per line) — the ceiling is lifted by an extension, the shape of the request does not change in the process.Wholesale price tiers (
price_tiers[]) — the field will appear next toprice, the semantics of the echo check will not change.Numeric SLAs — we publish statuses and the commitment "accepted fast, delivery is asynchronous"; we do not promise numeric delivery times.
Topping up the balance through the API — in wave 1 it goes through the channel from §1.
Self-service company verification — registration is yours, we verify the company; after that you issue keys yourself in the dashboard (§1).
Setting the balance.low threshold through the API — in wave 1 it goes through the channel from §1 (§6.2).
A machine list of events and deliveries — you can replay (§6.1) only an event whose
event_idyou have already seen.
11. Compatibility and deprecation
The commitment: no earlier than 90 days. Any field, endpoint or error code that we decide to remove or change in a breaking way is withdrawn no earlier than 90 days after the announcement. The announcement is a line in the changelog with the effective date.
We consider breaking: removing an endpoint, removing a field from a response, narrowing the allowed values, a new mandatory field in a request, changing the meaning of an existing error code, changing the UNIT or the meaning of an existing field while its name and type stay the same, as well as the format of the X-Kodrio-Signature header and the signature algorithm — all of them break a receiver silently, so they are announced with the same 90 days.
🔴 The change of unit was added on 2026-09-02, and added because of our own mistake. This class was not
on the list, and it is the quietest possible one: the field name is the same, the type is the same, the schema passes, the number
is different. That is exactly how market_price rode for a month in rouble kopecks under the label "minor units"
next to the cent price. The item was added so that next time it falls under the 90 days by rule,
not by someone's attentiveness.
🪤 Twice we did not keep this commitment, and we say so here, not only in the changelog.
2026-08-31 (the accounting currency of the environment) and 2026-09-02 (the unit of market_price) — both entries are 🔴 without
a preceding 🟡 and without 90 days. The ground in both cases is the same and is named in the entries themselves: a probe of
the live database showed that the endpoint has not a single live consumer — not a single key has been issued. The rule has not been cancelled and remains in force: as soon as the contract gets
its first live reader, the bypass will stop being possible, because its only ground will stop being true.
A bypass without a fresh probe with numbers in the entry is not a bypass but a violation.
Not breaking (arrives without 90 days and without warning — your client must survive this):
a new optional field in a response — do not fail on unfamiliar fields;
a new value in a list (a new brand, a new region, a new delivery kind);
a new error code within an existing HTTP class — handle by HTTP status, not only by the list of known codes;
a new webhook event — you receive only those you are subscribed to.
The rule on our side: the contract changes — a line in the changelog in the same change, otherwise the change is not considered made.
Active deprecation announcements
| What | Announced | Removed | Replaced by |
|---|---|---|---|
the code_format field of a catalogue row (GET /v1/catalog) | 2026-08-20 | 2026-11-18 | code_formats — an array of formats of the "item × supplier" pair, §4 |
🔴 Why this is breaking and not a "new optional field". The appearance of code_formats by itself
is not breaking. What is breaking is that we withdraw a promise that §5.5 and §9 gave:
"validate the delivery by code_format". It was wrong — the format depends on who prints the
code, not only on the item — and a client that checks the delivery against a single format rejects
a valid paid code. We chose to call this breaking and give the full 90 days rather than quietly
redefine the meaning of an existing field.
🔴 We announce a new element in code_formats IN ADVANCE, even though formally it is a "new value in
a list". By the letter of the rule above such a thing arrives without warning — but that is exactly where
your check breaks: we connected a supplier with its own press, while your reference list holds yesterday's
array, and you will reject a valid paid code. Therefore: the appearance of a new format for an existing
SKU goes as a line in the changelog in advance, as a breaking one. On your side we ask for
the same in return: re-read code_formats together with the catalogue, not once at integration.
What to do before 2026-11-18: switch the check to code_formats (one line: "passes any
of"). Until that date both fields come. ⚠️ Do not treat code_format as the first element of the array — it
describes the item, not the pair, and coincides with one of the code_formats only when the printing
supplier does not change the format. That is exactly why it cannot be relied on.
12. Endpoint reference /v1
The complete list. Endpoints that are not here do not exist; everything that exists is here.
⚠️ "Built" ≠ "available to your key right now": every endpoint answers at the live address, but an order with a live key is not accepted yet (§10.1). The column speaks about the readiness of the contract.
| Endpoint | Scope | Section | State |
|---|---|---|---|
GET /v1/catalog | catalog:read | §4 | ✅ built |
GET /v1/catalog/export | catalog:read | §4 | ✅ built |
GET /v1/stock | catalog:read | §4 | ✅ built |
GET /v1/recipients/fields | catalog:read | §4 | ✅ built |
GET /v1/balance | balance:read | §4 | ✅ built |
GET /v1/balance/entries | balance:read | §4 | ✅ built |
GET /v1/webhooks | webhooks:manage | §6.1 | ✅ built |
POST /v1/webhooks | webhooks:manage + the right to the events | §6.1 | ✅ built |
DELETE /v1/webhooks/{id} | webhooks:manage | §6.1 | ✅ built |
POST /v1/webhooks/{id}/replay | webhooks:manage | §6.1 | ✅ built |
POST /v1/orders | orders:create | §5 | ✅ built; intake with a live key is not open yet (§10.1) |
GET /v1/orders/{id} | orders:read | §5.5 | ✅ built |
GET /v1/orders | orders:read | §5.6 | ✅ built |
GET /v1/openapi.json | any live key, no scope | §12 | ✅ built |
POST /v1/recipients/check | — | — | ⛔ not built (§10.4) |
GET /v1/openapi.json — the machine-readable contract. The same contract as this page, as an
OpenAPI 3.1 file: generate your client from it or import it into Postman by URL. Any of your active
keys opens it, no particular scope is required; without a key you get
401 unauthorized in the usual §8 envelope. The file comes as is, without the {"data": …} envelope,
so that tools can import it by URL; the price file GET /v1/catalog/export is likewise served without the
envelope. The request counts against
the reading quota (§7). If the file and this page disagree, tell us: we treat that as a
defect.
13. If something went wrong
Suspected key leak
Signs: 403 ip_not_allowed with an unchanged outgoing network · movements in the balance ledger
that you did not make · a subscription in GET /v1/webhooks that you did not create · last_used_at
on a key you do not use.
The procedure — all three steps; revoking the key by itself does NOT close the leak:
Revoke the key in the dashboard, section "Access keys". Access stops instantly, we do not cache the decision on a key.
Go through GET /v1/webhooks and revoke all subscriptions you did not create. Subscriptions belong to the company and survive the revocation of a key: someone else's subscription will keep sending your balance and your orders to someone else's address until you remove it.
Reconcile the balance ledger (
GET /v1/balance/entries) for the period since the moment the key might have leaked, and tell us about movements you did not make.
Do not widen the list of allowed addresses to "fix" 403 ip_not_allowed — that is exactly
the step the person who took the key is waiting for.
Other
Take the
request_idfrom the error body.Check §8 — most refusals are fixed on your side and are described there.
500/503/a timeout on an order — §5.4: repeat with the sameIdempotency-Key, never with a new one. Except a500on a correct order while intake is not open (§10.1), and503 sandbox_not_available(§8).If it is still unclear — write through the channel from §1, naming the
request_id, the time and the endpoint.
A question whose answer is not on this page is our shortcoming, not your question. Tell us about it: the page is fixed with text, not with an oral answer.