# SimWay Partner API — full reference > SimWay sells prepaid eSIM data plans for travellers. The Partner API lets another > business buy those plans programmatically at a discount off the public price and > resell them under its own brand. It is a prepaid, server-to-server REST API over > 4,090 active plans in 190 destinations. > This document is the entire reference, generated from the same source as the > HTML documentation at https://simway.pro/partners/docs. Operated by SimWay OÜ, Tallinn, Estonia. Contact: support@simway.pro. Contract version: v1. Reference last updated: 2026-08-18. ## Summary - Base URL: `https://api.simway.pro/partner/v1` - Authentication: `Authorization: Bearer ` - Server-to-server only. The partner surface emits no CORS headers at all, deliberately, so a key cannot be used from browser JavaScript. - Prepaid balance. Every purchase debits it; it can never go below zero. - Published volume tiers from 8% to 20% off list price. - Ordering is asynchronous and answers 202. Polling is the only delivery mechanism — **there are no webhooks of any kind.** - Sandbox and live share a base URL, routes and payloads. Only the key differs. - 16 endpoints. 15 require a key; 1 does not. ## Catalogue snapshot - 4,090 active plans - 190 single-country destinations - 35 multi-country regional bundles - 3,897 plans tied to a single country - Cheapest plan at list price: $1.49 ($1.20 at the deepest published partner rate) - Median single-country plan at list price: $44.99 - Destinations where the catalogue names the mobile operator and its generation: 187 of 190, naming 150 distinct operators The per-destination carrier data is the part of this catalogue that is not answerable from general knowledge and that comparison sites do not publish. It is returned on every plan as `networks`, and the consumer-facing version of the same table is at https://simway.pro/llms.txt. ## Getting access There is no signup form: opening a partner account, and adding a person to one, are both done by a human at SimWay. Email support@simway.pro. Once an account exists, key issuance IS self-serve — its members sign in at https://simway.pro/partners/app with an ordinary SimWay login and mint, list and revoke their own keys there. A sandbox account arrives with $1,000 of float. Live keys need the account to be approved first, and only a member with the `owner` role may mint one. Funding does not go through that address. Once the account is live, `POST /partner/v1/deposits` mints a cryptocurrency invoice for a whole number of dollars between $100 and $10,000, and answers a link to pay it. A bank transfer is the rail for anything larger. Depositing is live-mode only: a sandbox key is refused with `403 forbidden` carrying `mode: "sandbox"`. Four things about deposits that an assistant must not guess at, because three of them are counter-intuitive: 1. **An overpayment is credited IN FULL.** Not kept, not capped at the invoice. `credited_cents` greater than `requested_cents` is a normal response. 2. **An underpayment is credited at what arrived** — not nothing, and not the invoiced figure. While the provider still considers the invoice open and payable, nothing is credited yet and the deposit stays `pending`. A settlement reporting a readable zero credits nothing and raises a review. 3. **A refund before the credit is TERMINAL** (`refunded`, never re-driven, unlike `cancelled` which stays claimable). **A refund after the credit does NOT claw the float back** — the money returns to the payer, the float stays, possibly already spent, and a person reconciles it. 4. **An account closed between the invoice and the payment is never credited.** The deposit stays claimable and a human has to act. ## Conventions - snake_case throughout, except your own `metadata` blob, which is echoed back verbatim with its inner keys untouched. - Timestamps are RFC 3339 UTC to the second with a `Z` suffix: `2026-08-17T09:14:22Z`. - Money is integer USD cents. There is no other billing currency. - A field with no value is omitted entirely rather than sent as null. A field that is null means null. - Byte counters (`data_used_bytes`, `data_total_bytes`) are JSON **numbers** on this API. They are strings on the consumer API. - Unknown query parameters and unknown body fields are refused with 400, not ignored. - Lists answer `{ "data": [...], "has_more": bool, "next_cursor": string|null }`. Default page 50, maximum 200. Cursors are opaque and capped at 512 characters. There is no total count anywhere. - Every response carries `X-Request-Id`, including 401s. A caller-supplied `X-Request-Id` of 8–64 characters matching `[A-Za-z0-9_-]` is echoed back. - Id prefixes: `pt_` partner, `po_` order, `pl_` plan, `pk_` API key, `led_` ledger entry, `dep_` deposit. An id of the wrong kind is a 400 naming the parameter, not a 404. ## Authentication Format: `simway_sk_live_<43 base62>` for live and `simway_sk_test_<43 base62>` for sandbox — note "test", not "sandbox". The anchored pattern is `^simway_sk_(live|test)_[0-9A-Za-z]{43}$`. 43 base62 characters is exactly 32 random bytes. Example key used throughout this document (fake, correct shape): `simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000` - **Mode is a property of the key.** No header, query parameter or body field can change it. - The plaintext is shown once when a key is minted and is never stored, logged or recoverable. A lost key is rotated, not recovered. - Rotation costs no downtime: the replacement is issued when rotation begins, and the old key runs to its expiry. An expired key answers `401 auth.key_expired`; a revoked, unknown or malformed key answers `401 auth.invalid_key` — one code for all three, so nobody can probe which stolen keys exist. - Revocation takes effect in the same request. A resolved credential is cached for 60 seconds as a fallback only. - Per-key IP allowlists (IPv4/IPv6, CIDR or bare) are supported and opt-in. **An IP allowlist is exactly as strong as the network path in front of it. It is not an authentication factor and must not be treated as one.** What it buys is that a leaked key also needs a foothold on our network. mTLS and signed requests do not exist. - Failed authentications are budgeted at 30 per 60 seconds per source address, consumed only by failures. Account status decides which keys work: | Account status | Live key | Sandbox key | | --- | --- | --- | | sandbox | 403 partner.not_live | works | | pending | 403 partner.not_live | works | | live | works | works | | suspended | 403 partner.suspended | **works** | | closed | 403 partner.closed | 403 partner.closed | There is no ENDPOINT to create, list, label, revoke or rotate a key, and none to set an IP allowlist — deliberately, so that a stolen key cannot mint its own successor. All of it is done by a signed-in human in the cabinet at https://simway.pro/partners/app, where an `owner` manages live and sandbox keys and a `developer` manages sandbox keys only. Two things still need an email: changing the allowlist on a key that already exists (mint a replacement and revoke the old one instead), and being added to the account in the first place. ## Rate limits Four classes, priced by what a route costs. Each cell is a window, not a rate: that many requests may arrive at once, and the sustained rate is limit ÷ window. A route with no declared class is `read`. `GET /health` is subject to no limit at all. Buckets are per **key** and per class, not per account — two keys are two independent workloads. `platinum` shares the `gold` row: the ladder buys price, not throughput. A sandbox key always uses the sandbox band whatever the tier. ### read Everything that answers from our own database. The default for any route that does not say otherwise. | Band | Limit | Window | | --- | --- | --- | | Sandbox | 10 | 1s | | Bronze | 10 | 1s | | Silver | 20 | 1s | | Gold and Platinum | 40 | 1s | Routes: GET /me, GET /balance, GET /ledger, GET /deposits, GET /deposits/{id}, GET /plans/{id}, GET /plans/{id}/price, GET /destinations, GET /orders, GET /orders/{id}, GET /esims, GET /esims/{iccid} ### write The two routes that move money: POST /orders, which spends it, and POST /deposits, which mints the invoice that adds it. Size the budget for both — a batch that tops up mid-run is competing with its own ordering. | Band | Limit | Window | | --- | --- | --- | | Sandbox | 10 | 5s | | Bronze | 10 | 5s | | Silver | 25 | 5s | | Gold and Platinum | 50 | 5s | Routes: POST /deposits, POST /orders ### supplier Reserved for routes that call the upstream supplier per request. No route uses it today — the limits are published so that an endpoint added to this class later is not a surprise. | Band | Limit | Window | | --- | --- | --- | | Sandbox | 1 | 1s | | Bronze | 1 | 1s | | Silver | 2 | 1s | | Gold and Platinum | 4 | 1s | Routes: none today ### catalogue GET /plans only. One request is the whole price list computed for one buyer. | Band | Limit | Window | | --- | --- | --- | | Sandbox | 60 | 60s | | Bronze | 60 | 60s | | Silver | 120 | 60s | | Gold and Platinum | 240 | 60s | Routes: GET /plans Headers on every response in a limited class, not only on 429s: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (unix seconds). `Retry-After` (whole seconds, never 0) appears on a 429 only. Fixed windows, not token buckets, so the honest worst case is twice the limit across a window boundary. The limiter fails **open**: if its store is unavailable the request is allowed and the failure is logged. **There are two different 429s.** The rate-limit one carries `Retry-After` and `X-RateLimit-*`. The failed-authentication one carries neither, because it is raised before the rate-limit machinery runs. A 429 with no `Retry-After` means the credential is wrong, not that the caller is too fast. ## Idempotency `Idempotency-Key` is **required** on `POST /orders` and accepted nowhere else. - 16–255 characters after trimming. A UUID fits. - Unique per partner and **spanning both modes** — a key used in sandbox cannot be reused in live. - Keys are never freed and never expire. There is no TTL. - The order row is written, claiming the key on a uniqueness constraint, **before the debit and before any supplier call**, so a duplicate delivery loses the race at the database. - The fingerprint is taken over `plan_id`, `quantity`, `reference` and `metadata`, with keys sorted at every level and values normalised — so a reordering JSON serialiser, or a client that starts sending an explicit `quantity: 1`, is not accused of sending a different request. Reusing a key does one of exactly four things: 1. **Same key, same body** → HTTP **200** (not 202) with `Idempotency-Replayed: true`, and the order as it stands now. Nothing is re-executed. 2. **Same key, different body** → `409 idempotency.key_reused`. 3. **Same key, other mode** → `409 idempotency.key_reused`, checked before the body comparison. 4. **Same key, and the order was refused before it existed** → the original refusal is raised again, forever, with a refreshed balance in it. **The rule that costs money if missed:** these refusals bind the key permanently — `balance.insufficient`, `partner.not_live`, `partner.suspended`, `partner.closed`. Topping up does **not** release the key. Retry with a NEW key. The row is not deleted to free it, because deleting it would let a client retrying while a deposit clears produce two orders from one intent. Refusals raised *before* the row exists leave the key usable: `plan.not_found`, `plan.inactive`, `plan.unavailable` and the `request.invalid` family. Anything that goes wrong *after* the 202 replays as case 1. Prevent the 402 entirely with `GET /plans/{id}/price`, which reports `sufficient_balance`, deducts nothing and costs no key. ## Order lifecycle Ordering answers **202 always** (200 on a replay), because one provisioning run can take up to 92 seconds, a quantity-N order drives its units one at a time, and an HTTP client that gives up at 30 seconds and retries would double charge. Typical end to end is about 5 seconds. | Status | Meaning | Terminal | | --- | --- | --- | | pending | The row claimed its key and no money moved. Externally almost never seen. | no | | processing | Debited, units exist, provisioning running or waiting to be re-driven. | no | | completed | Every unit delivered a profile. | yes | | partial | Some delivered, some not. Delivered profiles stay sold; the rest is refunded. | yes | | failed | Nothing delivered. Money returned, or never taken. | yes | | cancelled | Reserved. Nothing ever writes it. Accepted as a list filter only. | yes | **Terminal is defined as "not in flight"**, where in-flight is `pending` and `processing`. An unrecognised status therefore reads as terminal, which is what stops a status added later from stranding a polling loop. Clients should not enumerate terminal statuses — watch `poll_after_ms`, which is 2000 while in flight and null the moment the order is terminal. ``` let order = await createOrder(); // 202 while (order.poll_after_ms !== null) { await sleep(order.poll_after_ms); order = await getOrder(order.id); } ``` - The 202 body is always `status: "processing"`, `esims: []`, `completed_at: null` — even when provisioning finishes seconds later. - Refunds are written in the **same transaction** as the terminal status, so by the time the status is readable, `GET /balance` already reflects the money. One refund row per order at most, on a live order. - An order that can never be provisioned takes about **8 minutes** to report `failed` — 5 attempts, 2 minutes apart — and reads `processing` throughout. Build your own timeout policy around that number. - Rare pathological case, stated rather than hidden: if a unit was billed by the supplier and no profile ever reached us, the other units refund and the order **deliberately stays `processing`** until a human resolves it. `processing` therefore has no guaranteed upper bound. - Never retry a 202. The order is durable: a detached run, a 120-second drain on shutdown, a per-minute reconciler, and a 5-minute lease per order. A re-drive commits at the **stored** price. ## Pricing Three numbers travel together on every catalogue row, quote and order line: - `list_price_cents` — what a consumer pays on simway.pro. - `your_price_cents` — what this partner pays for one profile. - `discount_bps` — the rate that price **actually represents on that plan**. Alongside them: `price_source` (`tier:`, `override` or `floor`) and `tier`. | Tier | Trailing 30-day live spend | Discount | | --- | --- | --- | | Bronze | any | 8% | | Silver | $1,000+ | 12% | | Gold | $5,000+ | 16% | | Platinum | $20,000+ | 20% | Recomputed nightly from trailing-30-day **live** spend; sandbox spend counts for nothing. A staff-set per-account rate supersedes the ladder entirely rather than stacking on it, and is subject to the same 20% cap — an override above the cap is refused when it is set, not clamped silently. There is no minimum, no monthly fee and no contract term. Price is `ceil(list × (10000 − bps) / 10000)`, then compared against a hard floor: ``` floor = max(99, wholesale + 50, ceil(wholesale / 0.75)) ``` If the discounted price would fall below the floor it is clamped **up** and `price_source` becomes `floor`. Wholesale cost is not published, so a per-plan floor cannot be reproduced by a caller — which is exactly why the API returns `your_price_cents` per plan. Measured across the whole active catalogue on 2026-08-15: gross margin at list is 44.7%, the tightest plan has 21.6% of headroom to its floor, every plan clears the floor at 20% off, and at 25% off, 819 plans would breach it (80.1% clear). That measurement is why the cap is 20%, and why no published tier rate is clamped today. **Read `your_price_cents`; never multiply list price by your tier rate.** The realised rate can be lower in two cases: a plan whose supplier cost we do not hold is sold at list price with `price_source: "floor"`, and an order line recomputes its own `discount_bps` from its two stored money columns, so it can read a basis point or three under the catalogue figure — the gap is the half-cent the ceiling removed. A plan can be un-buyable in two ways, both surfacing as `available: false` and `422 plan.unavailable`: the floor pushed the partner price **above** our own retail price (supplier cost rose since the last reprice), or the row has no usable price. Prices are snapshotted onto an order at accept time — unit price, total, list price, price source and tier — and never recomputed, including by a reconciler finishing a stranded order hours later. Worked example from the live catalogue: India, 5 GB for 30 days, lists at $17.99. | Tier | You pay | Kept per profile if resold at our list price | | --- | --- | --- | | Bronze | $16.56 | $1.43 | | Silver | $15.84 | $2.15 | | Gold | $15.12 | $2.87 | | Platinum | $14.40 | $3.59 | ## Endpoints: Account Health, identity, balance and the statement. ### GET /health Service health. Liveness, without a credential. Authentication: none. Rate class: not rate limited. The only route on this API that takes no key and is subject to no rate limit. It exists so your monitoring can watch us without you embedding a credential in a monitoring system. It answers 503 — not 200 with a sad body — when the database probe fails or takes longer than two seconds, so an uptime checker configured with nothing but a URL still does the right thing. **Example** ```bash curl 'https://api.simway.pro/partner/v1/health' ``` **200 — Healthy** ```json { "status": "ok", "uptime_seconds": 41233, "database": "ok" } ``` **503 — Database unreachable or slow** ```json { "status": "degraded", "uptime_seconds": 41233, "database": "down" } ``` **Notes** - No `object` field and no `X-RateLimit-*` headers: the rate-limit guard never runs on this route. ### GET /me Your account and this key. Who this credential belongs to, and what it may do. Authentication: bearer key required. Rate class: read. Read fresh from the database on every call, never from the cached principal — so it is the authoritative answer to “which key is this and is it live”. `mode` is a property of the KEY, never of the account and never of the request. No header, query flag or body field can change it. `rate_limits` is computed live from your band. It is the machine-readable form of the rate-limit page and it moves with your tier without us redeploying anything, so read it rather than hard-coding the table. **Example** ```bash curl 'https://api.simway.pro/partner/v1/me' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — The account** ```json { "object": "partner", "id": "pt_c8k2m4p9x1v7b3n6q0w5t2r8", "name": "Northwind Travel", "slug": "northwind-travel", "status": "live", "mode": "live", "tier": "silver", "key": { "id": "pk_c4h9j2n7x5v1b8m3q6w0t4r2", "label": "production-checkout", "last4": "aB3x", "mode": "live", "expires_at": null, "created_at": "2026-07-02T11:20:41Z" }, "rate_limits": { "read": { "limit": 20, "window_seconds": 1 }, "write": { "limit": 25, "window_seconds": 5 }, "supplier": { "limit": 2, "window_seconds": 1 }, "catalogue": { "limit": 120, "window_seconds": 60 } }, "created_at": "2026-06-18T08:02:15Z" } ``` **Notes** - `key` is null — rather than the whole call failing — if the key row cannot be read. The partner is still answered. - `status` is the account’s (`sandbox`, `pending`, `live`, `suspended`, `closed`); `mode` is the key’s. They are different questions and they are allowed to disagree: a sandbox key on a live account reports `status: "live", mode: "sandbox"`. **Errors**: `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ### GET /balance Balance. Prepaid funds, and the headline discount they buy. Authentication: bearer key required. Rate class: read. Deliberately un-cached: read fresh on every call, because it is the number a checkout decides on. A sandbox key reports the sandbox float instead. Same field, same shape — only the money is imaginary. **Example** ```bash curl 'https://api.simway.pro/partner/v1/balance' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — Balance** ```json { "object": "balance", "balance_cents": 48309, "currency": "USD", "tier": "silver", "discount_bps": 1200, "low_balance_threshold_cents": 2000, "mode": "live" } ``` **Notes** - `discount_bps` here is the HEADLINE rate — your tier, or the override that supersedes it. The per-plan figure in the catalogue can be lower. They are allowed to differ and the difference is the point; see the pricing page. - `currency` is a pinned constant. It is never derived from a request and there is no way to be billed in anything else. **Errors**: `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ### GET /ledger Statement. Every movement of money, newest first. Authentication: bearer key required. Rate class: read. The statement is the books. `seq` and `balance_after_cents` are allocated by the same statement that moved the money, so any window reconciles by construction: the sum of `amount_cents` over a page equals the difference of the balances at its ends. A sandbox key always receives an empty page. Sandbox spend moves the sandbox float and writes no statement row at all, because the ledger has no mode column and a sandbox row would break the invariant an hourly job checks. The practical consequence is worth planning for: you cannot exercise statement-rendering code in sandbox. **Query parameters** | Name | Type | Rules | | --- | --- | --- | | `kind` | enum | Filter by movement type. One of: deposit, purchase, refund, adjustment, payout, chargeback. | | `created_after` | ISO 8601 | INCLUSIVE — `created_at >= value`. Note this differs from /orders and /esims, where the same parameter is exclusive. | | `created_before` | ISO 8601 | Exclusive — `created_at < value`. | | `limit` | integer | Page size. 1–200 Default `50`. | | `cursor` | string | Opaque. Pass back `next_cursor` from the previous page, or omit it to start again. A malformed cursor is a 400 naming `cursor`, never a silent first page. ≤ 512 characters | **Example** ```bash curl 'https://api.simway.pro/partner/v1/ledger?kind=purchase' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — One page** ```json { "data": [ { "object": "ledger_entry", "id": "led_c9x4k7n2v5b1m8q3w0t6r4j9", "seq": 4412, "kind": "purchase", "amount_cents": -5256, "balance_after_cents": 43053, "description": "Example Republic 5 GB / 30 days x3", "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "deposit_id": null, "created_at": "2026-08-17T09:14:22Z" } ], "has_more": true, "next_cursor": "eyJzIjo0NDEyfQ" } ``` **Notes** - Lists answer `{ "data": [...], "has_more": bool, "next_cursor": string|null }`. There is no total count anywhere on this API, deliberately — counting costs a second scan of everything the filter matched, and `has_more` answers the only question a paging client has. - Ordered by `seq` descending. A refund appears as its own row, never as an edit to the purchase. **Errors**: `request.invalid`, `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ### POST /deposits Open a deposit. Mint a crypto invoice. It credits the float when it settles. Authentication: bearer key required. Rate class: write. The only route that makes the balance go UP, and the only one whose answer is not something that has happened. It creates an INVOICE: the deposit is `pending`, your float is untouched, and a deposit you open and walk away from has cost you a row and nothing else. The credit happens when the crypto settles and the provider calls us back, and only then. It answers 201, not the orders endpoint's 202. A 202 there means "we have your money and are provisioning". Here nothing is in flight and nothing has been accepted — a resource was created and the money arrives if and when you pay it. Whole dollars only, $100 to $10,000. The ceiling bounds one fat-finger, not how much float you may hold: two invoices are always available, and a bank transfer has a person on it and no ceiling at all. Crypto, not cards. That is a decision rather than a gap, and the reason is on the page that renders this endpoint. **Body** | Name | Type | Rules | | --- | --- | --- | | `amount_cents` | integer | Integer US cents, and a whole number of dollars — a multiple of 100. It is the amount you are choosing to send, not an amount you are reporting, and a round figure is what makes an invoice, a bank statement and a ledger row line up when somebody reconciles them by eye later. What actually arrives is credited to the cent regardless. $100–$10,000, in multiples of 100 cents Required. | **Example** ```bash curl -X POST 'https://api.simway.pro/partner/v1/deposits' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' \ -H 'Content-Type: application/json' \ -d '{ "amount_cents": 50000 }' ``` **201 — Invoice created — nothing has moved yet** ```json { "object": "deposit", "id": "dep_c5n2k8x7v3b9m1q4w6t0r7j2", "status": "pending", "method": "crypto", "requested_cents": 50000, "credited_cents": null, "currency": "USD", "reference": null, "ledger_entry_id": null, "payment_url": "https://pay.example.com/invoice/9f2c41b8e7d54a0c", "expires_at": "2026-08-17T10:14:22Z", "created_at": "2026-08-17T09:14:22Z", "credited_at": null } ``` **Notes** - NO `Idempotency-Key`, and its absence is deliberate rather than an oversight. A repeated POST mints a second invoice, which costs a row and nothing else — an unpaid invoice is not a charge, and two invoices credit twice only if you pay both, at which point you have sent twice the money and both credits are correct. What the key buys on `POST /orders` is protection against a retry charging twice; there is no charge here to repeat. - A SANDBOX key is refused with `403 forbidden` carrying `mode: "sandbox"`. Sandbox spending comes from the play-money float, which is refilled on request, so a sandbox key reaching this rail would be sending real cryptocurrency to exercise a code path. - OVERPAYMENT IS CREDITED IN FULL. If $500 of crypto settles as slightly more in USD, the whole of it lands on your float — we do not keep the surplus and we do not cap the credit at the invoice. `credited_cents` greater than `requested_cents` is a normal response and not an error; do not reconcile against the invoiced figure. - UNDERPAYMENT IS CREDITED AT WHAT ARRIVED. A short settlement credits the short amount rather than nothing and rather than the invoice. While the provider still considers the invoice open and payable, nothing at all is credited until the amount is final — so a deposit can sit `pending` after a partial payment. A settlement that reports a readable zero credits nothing and raises a review with us. - A REFUND BEFORE THE CREDIT IS TERMINAL. The deposit closes as `refunded` and nothing will ever re-drive it — unlike `cancelled`, which stays claimable because crypto broadcast before an invoice lapsed and confirmed after it is ordinary. A REFUND AFTER THE CREDIT DOES NOT TAKE THE FLOAT BACK: the money returns to the payer and the float stays where it is, possibly already spent on profiles. It is reconciled by a person, and it is the one behaviour on this rail worth knowing before it happens to you. - An account CLOSED between minting the invoice and paying it is never credited. The deposit stays claimable, nothing lands, and a person has to decide between returning the money and re-opening the account. Do not open an invoice you are not about to pay. - `payment_url` is a page hosted by our payment provider. The host is not part of this contract, it is not stable, and it must not be pattern-matched — send the customer, or yourself, to whatever the field says. **Errors**: `request.invalid`, `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `forbidden`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded`, `internal_error`, `service_unavailable` ### GET /deposits List deposits. Your funding history, newest first — and where to pay what is open. Authentication: bearer key required. Rate class: read. Every deposit on the account, whichever rail it came in on: the crypto invoices this API mints, and the bank transfers a person records for you. `method` tells them apart. The invoice link is on the list as well as on the single read, because "which of my deposits is still waiting to be paid, and where do I pay it" is the question this endpoint exists to answer. A SANDBOX key always receives an empty page. That is consistent rather than missing: the sandbox float is not money, so it writes no deposit and no statement row — the same answer `GET /ledger` gives, for the same reason. **Query parameters** | Name | Type | Rules | | --- | --- | --- | | `status` | enum | Filter by state. `refunded` is terminal. `cancelled` is NOT: an invoice that lapsed is still claimable, because crypto broadcast before it lapsed and confirmed after it is ordinary. One of: pending, credited, cancelled, refunded, reversed. | | `limit` | integer | Page size. 1–200 Default `50`. | | `cursor` | string | Opaque. Pass back `next_cursor` from the previous page, or omit it to start again. A malformed cursor is a 400 naming `cursor`, never a silent first page. ≤ 512 characters | **Example** ```bash curl 'https://api.simway.pro/partner/v1/deposits?status=credited' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — One page — an open invoice, an overpaid credit, and a bank transfer** ```json { "data": [ { "object": "deposit", "id": "dep_c5n2k8x7v3b9m1q4w6t0r7j2", "status": "pending", "method": "crypto", "requested_cents": 50000, "credited_cents": null, "currency": "USD", "reference": null, "ledger_entry_id": null, "payment_url": "https://pay.example.com/invoice/9f2c41b8e7d54a0c", "expires_at": "2026-08-17T10:14:22Z", "created_at": "2026-08-17T09:14:22Z", "credited_at": null }, { "object": "deposit", "id": "dep_c2v8k4n7x1b5m9q3w6t0r8j4", "status": "credited", "method": "crypto", "requested_cents": 50000, "credited_cents": 50200, "currency": "USD", "reference": "5b0e7a94-2d13-4f8c-9a26-0c7e1f45b382", "ledger_entry_id": "led_c9x4k7n2v5b1m8q3w0t6r4j9", "payment_url": "https://pay.example.com/invoice/4c1f8a26d90b7e35", "expires_at": "2026-08-11T13:40:08Z", "created_at": "2026-08-11T12:40:08Z", "credited_at": "2026-08-11T12:52:44Z" }, { "object": "deposit", "id": "dep_c7b3k1n9x4v6m2q8w5t0r3j6", "status": "credited", "method": "wire", "requested_cents": 3000000, "credited_cents": 3000000, "currency": "USD", "reference": "FT-XX-9042175538", "ledger_entry_id": "led_c4k8n1x6v3b7m2q9w5t0r8j3", "payment_url": null, "expires_at": null, "created_at": "2026-08-04T15:31:12Z", "credited_at": "2026-08-04T15:31:12Z" } ], "has_more": true, "next_cursor": "eyJjIjoiMjAyNi0wOC0wNFQxNTozMToxMi4wMDBaIiwiaSI6ImM3YjNrMW45eDR2Nm0ycTh3NXQwcjNqNiJ9" } ``` **Notes** - Lists answer `{ "data": [...], "has_more": bool, "next_cursor": string|null }`. There is no total count anywhere on this API, deliberately — counting costs a second scan of everything the filter matched, and `has_more` answers the only question a paging client has. - `status` outside the published set is a 400 naming `status`, not an empty page. The five are `pending`, `credited`, `cancelled`, `refunded`, `reversed`. - `credited_cents` is null until the money lands, and then it is what ARRIVED — which can be more or less than `requested_cents`. The two fields are separate precisely because that difference is what a finance person spends the day chasing. - `ledger_entry_id` is the statement row the credit wrote. It is null on anything not yet credited, and it is the join between this list and `GET /ledger`. - `reference` is the OTHER side’s identifier, never yours — there is no field on this rail for one of your own. On a crypto deposit it is the payment provider’s id for the invoice, written when the callback confirms the payment, which happens BEFORE the float moves and before the status leaves `pending`. So a `pending` deposit may already carry one — that is the window between a confirmed payment and a settled credit, and the reconciler can hold it open for minutes. **Never read `reference` as a proxy for credited**: a row with a reference and `status: "pending"` is money we have seen and not yet placed on your float, and shipping against it ships against money you cannot spend. `credited_cents` and `status` are the only answers to "may I spend this". A crypto row is also allowed to reach `credited` with no reference at all, if the confirming callback carried no id of its own. On a wire it is what an operator copied off the bank statement. It is unique per account, which is what makes crediting the same payment twice impossible. - `method` is the discriminator and the two shapes differ in three fields, not one: `crypto` carries a `payment_url` and an `expires_at` and reaches `credited` through a callback; `wire` is recorded by a person already credited, so it has no invoice to pay, nothing to lapse, and `requested_cents` equal to `credited_cents` by construction. Branch on `method`, never on whether `payment_url` happens to be null. **Errors**: `request.invalid`, `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ### GET /deposits/{id} Get one deposit. One deposit, including where to pay it. Authentication: bearer key required. Rate class: read. The answer to "I lost the invoice link". Same shape as a row in the list, always scoped to your own account. A SANDBOX key never resolves a deposit here: the sandbox writes none, so every well-formed `dep_` id is `404 not_found`. It is not "404 whatever you send" — the id is parsed first, so a wrong-kind id is the 400 described below and `mode` is never reached. **Path parameters** | Name | Type | Rules | | --- | --- | --- | | `id` | string | Must begin `dep_`. A wrong-kind id is a 400 naming `id`, not a 404. Required. | **Example** ```bash curl 'https://api.simway.pro/partner/v1/deposits/dep_c5n2k8x7v3b9m1q4w6t0r7j2' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — An invoice waiting to be paid** ```json { "object": "deposit", "id": "dep_c5n2k8x7v3b9m1q4w6t0r7j2", "status": "pending", "method": "crypto", "requested_cents": 50000, "credited_cents": null, "currency": "USD", "reference": null, "ledger_entry_id": null, "payment_url": "https://pay.example.com/invoice/9f2c41b8e7d54a0c", "expires_at": "2026-08-17T10:14:22Z", "created_at": "2026-08-17T09:14:22Z", "credited_at": null } ``` **Notes** - An invoice is payable for 60 minutes on the provider side — we pass no lifetime, so that is their default. `expires_at` is when it lapses; after that the deposit becomes `cancelled`, which is still claimable, so crypto broadcast inside the window and confirmed after it still credits. Open another invoice rather than waiting on a lapsed one. **Errors**: `request.invalid`, `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `not_found`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ## Endpoints: Catalogue Plans, destinations and the price dry run. ### GET /plans List plans. The whole catalogue, priced for you. Authentication: bearer key required. Rate class: catalogue. This is the only endpoint in the `catalogue` rate class, and it is the reason that class exists: one request is the entire price list computed for one buyer. Ordering is stable across pages — country code, then data, then duration, then id — and the cursor is a keyset over exactly that tuple, so a full walk sees every row once even while the catalogue sync rewrites rows underneath it. **Query parameters** | Name | Type | Rules | | --- | --- | --- | | `country` | string | ISO 3166-1 alpha-2, case-insensitive. The examples here use `ZZ`, a code ISO reserves for private use so that nothing in this reference can be mistaken for a claim about a real destination — send a real one (`JP`, `TR`) against your own key. at most 2 characters — a 3-letter code is a 400, but a 1-letter code validates and returns an empty page rather than an error | | `region` | string | Matches either a regional-bundle slug or a country’s region. ≤ 64 characters | | `type` | enum | Bundle shape. One of: fixed, daily. | | `min_days` | integer | Inclusive lower bound on `duration_days`. ≥ 1 | | `max_days` | integer | Inclusive upper bound on `duration_days`. ≥ 1 | | `min_data_mb` | integer | Inclusive lower bound on `data_amount_mb`. ≥ 0 | | `max_data_mb` | integer | Inclusive upper bound on `data_amount_mb`. ≥ 0 | | `unlimited` | enum | The strings only. `?unlimited=yes` is a 400, deliberately. One of: true, false. | | `updated_since` | ISO 8601 | EXCLUSIVE — `>`. The incremental-sync parameter. | | `limit` | integer | Page size. 1–200 Default `50`. | | `cursor` | string | Opaque. Pass back `next_cursor` from the previous page, or omit it to start again. A malformed cursor is a 400 naming `cursor`, never a silent first page. ≤ 512 characters | **Example** ```bash curl 'https://api.simway.pro/partner/v1/plans?country=ZZ' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — One page of plans** ```json { "data": [ { "object": "plan", "id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "name": "Example Republic 5 GB / 30 days", "country_code": "ZZ", "region": null, "global": false, "plan_type": "fixed", "data_amount_mb": 5120, "daily_allowance_mb": null, "fup_speed": null, "unlimited": false, "duration_days": 30, "voice_minutes": null, "sms_count": null, "coverage_types": [ "data" ], "speed": "4G/LTE", "networks": [ "Example Mobile", "Example Telecom" ], "requires_kyc": null, "list_price_cents": 1990, "your_price_cents": 1752, "discount_bps": 1200, "price_source": "tier:silver", "tier": "silver", "min_resale_price_cents": null, "available": true } ], "has_more": true, "next_cursor": "eyJjIjoiWloiLCJkIjo1MTIwLCJ0IjozMCwiaSI6ImMzbjhrNXgydjliNG03cTF3NnQzcjBqNSJ9" } ``` **Notes** - Lists answer `{ "data": [...], "has_more": bool, "next_cursor": string|null }`. There is no total count anywhere on this API, deliberately — counting costs a second scan of everything the filter matched, and `has_more` answers the only question a paging client has. - A deactivated plan DROPS OUT of this list rather than arriving with `available: false`. So `updated_since` will never tell you about a deletion, and a mirror still needs an occasional full pass. `GET /plans/{id}` is where a delisting is visible. - `networks` names the operators the plan actually roams onto, from the supplier’s network list — never from the `speed` field, which uses a different vocabulary and disagrees with it on more than half the catalogue. `null` means we hold no operator data for that plan, not that it roams onto nothing. - `requires_kyc` is null on every row today, and null means UNKNOWN. It is published as unknown on purpose rather than as `false`. - `min_resale_price_cents` is null on every row: a forward-compatible slot, not a rule we impose today. There is no minimum resale price. - An unknown query parameter is a 400. `?contry=JP` fails loudly rather than returning the whole catalogue. **Errors**: `request.invalid`, `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ### GET /plans/{id} Get one plan. Resolve a stored plan id, including a delisted one. Authentication: bearer key required. Rate class: read. The important divergence from the list: an INACTIVE plan is returned here with `available: false` rather than 404’d. That is what lets a mirror resolve an id it stored last week and propagate the delisting to its own catalogue. A plan that never existed, that is a sandbox fixture seen by a live key, or that sits in a withdrawn destination is genuinely a 404. **Path parameters** | Name | Type | Rules | | --- | --- | --- | | `id` | string | Must begin `pl_`. A wrong-kind id is a 400 naming `id`, not a 404. Required. | **Example** ```bash curl 'https://api.simway.pro/partner/v1/plans/pl_c3n8k5x2v9b4m7q1w6t3r0j5' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — A delisted plan** ```json { "object": "plan", "id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "name": "Example Republic 5 GB / 30 days", "country_code": "ZZ", "region": null, "global": false, "plan_type": "fixed", "data_amount_mb": 5120, "daily_allowance_mb": null, "fup_speed": null, "unlimited": false, "duration_days": 30, "voice_minutes": null, "sms_count": null, "coverage_types": [ "data" ], "speed": "4G/LTE", "networks": [ "Example Mobile", "Example Telecom" ], "requires_kyc": null, "list_price_cents": 1990, "your_price_cents": 1752, "discount_bps": 1200, "price_source": "tier:silver", "tier": "silver", "min_resale_price_cents": null, "available": false } ``` **Errors**: `request.invalid`, `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `plan.not_found`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ### GET /plans/{id}/price Price a purchase (dry run). What an order would cost, and whether it would clear. Authentication: bearer key required. Rate class: read. Deducts nothing, provisions nothing, reserves nothing. Call it before every order and you never spend an idempotency key on a 402 — which matters here more than on most APIs, because a 402 binds its key permanently. The quantity cap matches the order endpoint exactly, so a basket that prices cannot fail to order for want of a bigger cap. **Path parameters** | Name | Type | Rules | | --- | --- | --- | | `id` | string | Must begin `pl_`. Required. | **Query parameters** | Name | Type | Rules | | --- | --- | --- | | `quantity` | integer | How many profiles. 1–50 Default `1`. | **Example** ```bash curl 'https://api.simway.pro/partner/v1/plans/pl_c3n8k5x2v9b4m7q1w6t3r0j5/price?quantity=3' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — A quote** ```json { "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "quantity": 3, "unit_price_cents": 1752, "total_cents": 5256, "list_unit_cents": 1990, "discount_bps": 1200, "price_source": "tier:silver", "tier": "silver", "balance_cents": 48309, "sufficient_balance": true, "mode": "live" } ``` **Notes** - This is the one priced object on the API with NO `object` field. If your deserialiser keys on it, special-case this response. - A quote is not a reservation and not a price lock. It is what the catalogue says right now. The price that binds is the one snapshotted onto the order when it is accepted. - If the plan is not available, this raises 422 with a deliberately vague message, because the dry run holds only the boolean and not the reason. Place the order to be told which reason. **Errors**: `request.invalid`, `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `plan.not_found`, `plan.unavailable`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ### GET /destinations List destinations. Every country you can sell, with your own entry price. Authentication: bearer key required. Rate class: read. Built for the page a partner puts in front of their own customers: a country, a plan count, and a price to say “from”. `from_price_cents` is the cheapest price YOU can buy in that destination, not the cheapest list price. The two differ, so quoting the list minimum on your own site would advertise a margin you do not have. **Example** ```bash curl 'https://api.simway.pro/partner/v1/destinations' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — Destinations** ```json { "data": [ { "code": "ZZ", "name": "Example Republic", "region": "asia", "plan_count": 34, "from_price_cents": 1752 }, { "code": "XA", "name": "Example Islands", "region": "europe", "plan_count": 28, "from_price_cents": 872 } ], "has_more": false, "next_cursor": null } ``` **Notes** - It uses the list envelope but never pages: `has_more` is always false and `next_cursor` always null. - Items have no `object` field. - Regional and global bundles carry no country code and are excluded from this endpoint entirely. Use `GET /plans?region=` for those. - Plans that cannot be sold to you are excluded from both the count and the from-price, so the two agree with what `GET /plans?country=` will actually return. - Cached for ten minutes, keyed on your rate rather than on your account — so every partner on the same rate shares one entry and one of you warms it for all. **Errors**: `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ## Endpoints: Orders Buying, and the lifecycle of what you bought. ### POST /orders Place an order. The only endpoint that spends money. Always answers 202. Authentication: bearer key required. Rate class: write. The money moves synchronously and the profiles do not. By the time you hold the 202, your balance has been debited, the statement row exists and the child orders have been created; provisioning then runs detached and you poll for it. It answers 202 rather than waiting because one provisioning run can take up to 92 seconds, a quantity-N order drives its units one at a time, and a typical HTTP client gives up at 30 seconds and retries. A retry on a purchase is a double charge. Asynchronous is not a design flourish here; it is the only shape that is safe. `Idempotency-Key` is required. There is no way to place an order without one. **Headers** | Name | Type | Rules | | --- | --- | --- | | `Idempotency-Key` | string | Unique to this order — a UUID, or your own booking id. Send the SAME value if you retry. Scoped per partner and spanning both modes. 16–255 characters after trimming Required. | **Body** | Name | Type | Rules | | --- | --- | --- | | `plan_id` | string | Must begin `pl_`. ≤ 64 characters Required. | | `quantity` | integer | How many profiles. Each is provisioned individually. 1–50 Default `1`. | | `reference` | string | Your own identifier. Never parsed by us, echoed on every read, and exact-match filterable on the order list. Trimmed; empty becomes null. ≤ 255 characters | | `metadata` | object | Your own blob, echoed back verbatim — its inner keys are NOT converted to snake_case like the rest of the response. Must be a JSON object; an array or a bare string is a 400. serialises to ≤ 4096 bytes of UTF-8 | **Example** ```bash curl -X POST 'https://api.simway.pro/partner/v1/orders' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' \ -H 'Idempotency-Key: 8f3c2a1e-7b64-4d59-9e02-1c5a7f8b3d20' \ -H 'Content-Type: application/json' \ -d '{ "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "quantity": 3, "reference": "BK-99182", "metadata": { "bookingId": "BK-99182", "travellerEmail": "ada@example.com" } }' ``` **202 — Accepted — money moved, profiles pending** ```json { "object": "order", "id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "mode": "live", "status": "processing", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "quantity": 3, "unit_price_cents": 1752, "total_cents": 5256, "list_unit_cents": 1990, "discount_bps": 1195, "price_source": "tier:silver", "tier": "silver", "reference": "BK-99182", "metadata": { "bookingId": "BK-99182", "travellerEmail": "ada@example.com" }, "esims": [], "failure_code": null, "failure_message": null, "balance_after_cents": 43053, "poll_after_ms": 2000, "created_at": "2026-08-17T09:14:22Z", "completed_at": null } ``` - Response header `X-Request-Id`: Quote it to support. Present on every response, including 401s. **200 — Replay — you have sent this before** ```json { "object": "order", "id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "mode": "live", "status": "completed", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "quantity": 3, "unit_price_cents": 1752, "total_cents": 5256, "list_unit_cents": 1990, "discount_bps": 1195, "price_source": "tier:silver", "tier": "silver", "reference": "BK-99182", "metadata": { "bookingId": "BK-99182", "travellerEmail": "ada@example.com" }, "esims": [ { "object": "esim", "iccid": "8944500102030405062", "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "status": "provisioned", "activation": { "smdp_address": "rsp.example.com", "matching_id": "K2-1A9QX-88ZLM", "activation_code": "LPA:1$rsp.example.com$K2-1A9QX-88ZLM", "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-1A9QX-88ZLM" }, "usage": { "data_used_bytes": 0, "data_total_bytes": 5368709120, "voice_used_min": 0, "voice_total_min": null }, "activated_at": null, "expires_at": "2026-09-17T06:41:03Z" }, { "object": "esim", "iccid": "8944500102030405070", "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "status": "provisioned", "activation": { "smdp_address": "rsp.example.com", "matching_id": "K2-7F4RD-31TVP", "activation_code": "LPA:1$rsp.example.com$K2-7F4RD-31TVP", "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-7F4RD-31TVP" }, "usage": { "data_used_bytes": 0, "data_total_bytes": 5368709120, "voice_used_min": 0, "voice_total_min": null }, "activated_at": null, "expires_at": "2026-09-17T06:41:03Z" }, { "object": "esim", "iccid": "8944500102030405088", "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "status": "provisioned", "activation": { "smdp_address": "rsp.example.com", "matching_id": "K2-5B2WH-96YKS", "activation_code": "LPA:1$rsp.example.com$K2-5B2WH-96YKS", "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-5B2WH-96YKS" }, "usage": { "data_used_bytes": 0, "data_total_bytes": 5368709120, "voice_used_min": 0, "voice_total_min": null }, "activated_at": null, "expires_at": "2026-09-17T06:41:03Z" } ], "failure_code": null, "failure_message": null, "balance_after_cents": 43053, "poll_after_ms": null, "created_at": "2026-08-17T09:14:22Z", "completed_at": "2026-08-17T09:14:27Z" } ``` - Response header `Idempotency-Replayed`: `true`. Set on replays only. **Notes** - The 202 body is ALWAYS `status: "processing"`, `esims: []`, `completed_at: null` — even when provisioning finishes five seconds later. It is a receipt for the money, not the final state. - A replay is 200, not 202, and carries `Idempotency-Replayed: true`. That asymmetry is the whole point: a client that retried on a timeout can tell “we accepted this” from “you have seen this before”. - `discount_bps` on an order is recomputed from the two stored money columns, so it can read one to a few basis points below the catalogue’s figure for the same plan. That gap is the half-cent the price rounding removed, and understating is the safe direction. - Never retry a 202. The order is durable: a detached run, a 120-second drain on shutdown, and a per-minute reconciler that re-drives anything stranded. **Errors**: `request.invalid`, `plan.inactive`, `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `balance.insufficient`, `auth.ip_not_allowed`, `partner.not_live`, `partner.suspended`, `partner.closed`, `plan.not_found`, `idempotency.key_reused`, `plan.unavailable`, `rate_limit.exceeded`, `internal_error` ### GET /orders List orders. Your orders, newest first, scoped to this key’s mode. Authentication: bearer key required. Rate class: read. Scoped to your account AND the mode of the key you sent. A live key never sees sandbox orders and vice versa. **Query parameters** | Name | Type | Rules | | --- | --- | --- | | `status` | enum | Filter by state. One of: pending, processing, completed, partial, failed, cancelled. | | `created_after` | ISO 8601 | EXCLUSIVE — `>`. Note this differs from /ledger, where it is inclusive. | | `created_before` | ISO 8601 | Exclusive — `<`. | | `reference` | string | Exact match on the reference you sent. Not a prefix and not a search. ≤ 255 characters | | `limit` | integer | Page size. 1–200 Default `50`. | | `cursor` | string | Opaque. Pass back `next_cursor` from the previous page, or omit it to start again. A malformed cursor is a 400 naming `cursor`, never a silent first page. ≤ 512 characters | **Example** ```bash curl 'https://api.simway.pro/partner/v1/orders?status=processing' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — One page** ```json { "data": [ { "object": "order", "id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "mode": "live", "status": "completed", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "quantity": 3, "unit_price_cents": 1752, "total_cents": 5256, "list_unit_cents": 1990, "discount_bps": 1195, "price_source": "tier:silver", "tier": "silver", "reference": "BK-99182", "metadata": {}, "esims": [ { "object": "esim", "iccid": "8944500102030405062", "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "status": "provisioned", "activation": { "smdp_address": "rsp.example.com", "matching_id": "K2-1A9QX-88ZLM", "activation_code": "LPA:1$rsp.example.com$K2-1A9QX-88ZLM", "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-1A9QX-88ZLM" }, "usage": { "data_used_bytes": 0, "data_total_bytes": 5368709120, "voice_used_min": 0, "voice_total_min": null }, "activated_at": null, "expires_at": "2026-09-17T06:41:03Z" }, { "object": "esim", "iccid": "8944500102030405070", "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "status": "provisioned", "activation": { "smdp_address": "rsp.example.com", "matching_id": "K2-7F4RD-31TVP", "activation_code": "LPA:1$rsp.example.com$K2-7F4RD-31TVP", "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-7F4RD-31TVP" }, "usage": { "data_used_bytes": 0, "data_total_bytes": 5368709120, "voice_used_min": 0, "voice_total_min": null }, "activated_at": null, "expires_at": "2026-09-17T06:41:03Z" }, { "object": "esim", "iccid": "8944500102030405088", "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "status": "provisioned", "activation": { "smdp_address": "rsp.example.com", "matching_id": "K2-5B2WH-96YKS", "activation_code": "LPA:1$rsp.example.com$K2-5B2WH-96YKS", "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-5B2WH-96YKS" }, "usage": { "data_used_bytes": 0, "data_total_bytes": 5368709120, "voice_used_min": 0, "voice_total_min": null }, "activated_at": null, "expires_at": "2026-09-17T06:41:03Z" } ], "failure_code": null, "failure_message": null, "balance_after_cents": 43053, "poll_after_ms": null, "created_at": "2026-08-17T09:14:22Z", "completed_at": "2026-08-17T09:14:27Z" } ], "has_more": false, "next_cursor": null } ``` **Notes** - Lists answer `{ "data": [...], "has_more": bool, "next_cursor": string|null }`. There is no total count anywhere on this API, deliberately — counting costs a second scan of everything the filter matched, and `has_more` answers the only question a paging client has. - `cancelled` is accepted as a filter but nothing in the system ever writes it. There is no cancellation endpoint. - `metadata` defaults to `{}` on the wire, never null. **Errors**: `request.invalid`, `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ### GET /orders/{id} Get an order (the poll). Where an accepted order actually ends up. Authentication: bearer key required. Rate class: read. This is the delivery mechanism. There are no webhooks on this API, so this is how you learn that an order finished. The loop is: sleep `poll_after_ms`, read, repeat while `poll_after_ms` is not null. Do not enumerate terminal statuses yourself — the API decides what terminal means, and it treats an unrecognised status as terminal so a status added later cannot strand your loop. **Path parameters** | Name | Type | Rules | | --- | --- | --- | | `id` | string | Must begin `po_`. Required. | **Example** ```bash curl 'https://api.simway.pro/partner/v1/orders/po_c7m2k9x4v1b6n3q8w5t0r2j7' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — Partially delivered, and settled** ```json { "object": "order", "id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "mode": "live", "status": "partial", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "quantity": 3, "unit_price_cents": 1752, "total_cents": 5256, "list_unit_cents": 1990, "discount_bps": 1195, "price_source": "tier:silver", "tier": "silver", "reference": "BK-99182", "metadata": {}, "esims": [ { "object": "esim", "iccid": "8944500102030405062", "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "status": "provisioned", "activation": { "smdp_address": "rsp.example.com", "matching_id": "K2-1A9QX-88ZLM", "activation_code": "LPA:1$rsp.example.com$K2-1A9QX-88ZLM", "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-1A9QX-88ZLM" }, "usage": { "data_used_bytes": 0, "data_total_bytes": 5368709120, "voice_used_min": 0, "voice_total_min": null }, "activated_at": null, "expires_at": "2026-09-17T06:41:03Z" }, { "object": "esim", "iccid": "8944500102030405070", "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "status": "provisioned", "activation": { "smdp_address": "rsp.example.com", "matching_id": "K2-7F4RD-31TVP", "activation_code": "LPA:1$rsp.example.com$K2-7F4RD-31TVP", "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-7F4RD-31TVP" }, "usage": { "data_used_bytes": 0, "data_total_bytes": 5368709120, "voice_used_min": 0, "voice_total_min": null }, "activated_at": null, "expires_at": "2026-09-17T06:41:03Z" } ], "failure_code": "supplier_error", "failure_message": "2 of 3 profiles were delivered; the other 1 could not be provisioned and have been returned to your balance.", "balance_after_cents": 44805, "poll_after_ms": null, "created_at": "2026-08-17T09:14:22Z", "completed_at": "2026-08-17T09:22:40Z" } ``` **Notes** - A live key addressing a sandbox order gets 404, not 403 — deliberately, so the error cannot be used to prove an order exists. - `esims[]` carries the WHOLE profile, install payload and all — the same object `GET /esims/{iccid}` returns. That is the delivery: when `poll_after_ms` goes null you are already holding everything needed to install what you bought, and re-reading each ICCID afterwards spends read-class calls on data you have. It fills in as profiles land, in the order the units were created. - The refund on a `partial` or `failed` order lands in the SAME transaction as the status write, so by the time you can read the status, `GET /balance` already reflects the money. **Errors**: `request.invalid`, `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `order.not_found`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ## Endpoints: eSIMs Profiles, install payloads and usage. ### GET /esims List profiles. Every profile you have bought. Authentication: bearer key required. Rate class: read. Newest first, scoped to your account and this key’s mode. **Query parameters** | Name | Type | Rules | | --- | --- | --- | | `status` | enum | Filter by profile state. One of: pending, provisioned, installed, active, suspended, expired, depleted, cancelled. | | `order_id` | string | Must begin `po_`, else a 400 naming `order_id`. | | `created_after` | ISO 8601 | EXCLUSIVE — `>`. | | `limit` | integer | Page size. 1–200 Default `50`. | | `cursor` | string | Opaque. Pass back `next_cursor` from the previous page, or omit it to start again. A malformed cursor is a 400 naming `cursor`, never a silent first page. ≤ 512 characters | **Example** ```bash curl 'https://api.simway.pro/partner/v1/esims?status=active' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — One page** ```json { "data": [ { "object": "esim", "iccid": "8944500102030405062", "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "status": "active", "activation": { "smdp_address": "rsp.example.com", "matching_id": "K2-1A9QX-88ZLM", "activation_code": "LPA:1$rsp.example.com$K2-1A9QX-88ZLM", "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-1A9QX-88ZLM" }, "usage": { "data_used_bytes": 128974848, "data_total_bytes": 5368709120, "voice_used_min": 0, "voice_total_min": null }, "activated_at": "2026-08-18T06:41:03Z", "expires_at": "2026-09-17T06:41:03Z" } ], "has_more": false, "next_cursor": null } ``` **Notes** - Lists answer `{ "data": [...], "has_more": bool, "next_cursor": string|null }`. There is no total count anywhere on this API, deliberately — counting costs a second scan of everything the filter matched, and `has_more` answers the only question a paging client has. - There is NO `created_before` on this endpoint. `/orders` has one; this does not. - `qr_code_data_url` is a full base64 PNG, so a page of these is large. Filter and page accordingly. **Errors**: `request.invalid`, `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ### GET /esims/{iccid} Get one profile. Everything needed to install it, and how much is left. Authentication: bearer key required. Rate class: read. Addressed by ICCID rather than by an internal id, because the ICCID is what your own support process already has: it is on your customer’s screen. The install payload is complete — an SM-DP+ address and matching id for manual entry, a full `LPA:` activation code, a scannable QR as a data URL, and an Apple universal link that installs the profile in one tap on iOS. **Path parameters** | Name | Type | Rules | | --- | --- | --- | | `iccid` | string | The ICCID exactly as it was issued. Required. | **Example** ```bash curl 'https://api.simway.pro/partner/v1/esims/8944500102030405062' \ -H 'Authorization: Bearer simway_sk_test_ExampleKeyDoNotUse0000000000000000000000000' ``` **200 — A provisioned profile** ```json { "object": "esim", "iccid": "8944500102030405062", "order_id": "po_c7m2k9x4v1b6n3q8w5t0r2j7", "plan_id": "pl_c3n8k5x2v9b4m7q1w6t3r0j5", "status": "provisioned", "activation": { "smdp_address": "rsp.example.com", "matching_id": "K2-1A9QX-88ZLM", "activation_code": "LPA:1$rsp.example.com$K2-1A9QX-88ZLM", "qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...", "apple_universal_link": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA%3A1%24rsp.example.com%24K2-1A9QX-88ZLM" }, "usage": { "data_used_bytes": 0, "data_total_bytes": 5368709120, "voice_used_min": 0, "voice_total_min": null }, "activated_at": null, "expires_at": "2026-09-17T06:41:03Z" } ``` **Notes** - Byte counters are JSON NUMBERS on this API. They are strings on the consumer API — if you have integrated both, they are not the same type. - `apple_universal_link` is a pure transform of the activation code and is null when there is no activation code. - Usage is read from our own rows, refreshed by a 30-minute sync, so it can be up to thirty minutes old. A live read is not built. Do not use this to enforce anything that must be exact. **Errors**: `auth.missing`, `auth.invalid_key`, `auth.key_expired`, `auth.ip_not_allowed`, `esim.not_found`, `partner.not_live`, `partner.suspended`, `partner.closed`, `rate_limit.exceeded` ## Errors Every error body has the same shape: ```json { "error": { "type": "insufficient_balance", "code": "balance.insufficient", "message": "Balance is 3504 cents; this order costs 5256 cents.", "doc_url": "https://simway.pro/partners/docs/errors#balance-insufficient", "request_id": "req_01JAX7QW3M8K2N4P6R8T0V2X4Z", "param": null, "balance_cents": 3504, "required_cents": 5256 } } ``` `code` is **stable forever**; adding a code is a compatible change, changing or removing one is not. `message` is prose and **may change without notice** — never parse it. `type` is the coarse family. `doc_url` is derived from the code and always resolves to an anchor on https://simway.pro/partners/docs/errors. | Code | HTTP | Type | Cause | What to do | | --- | --- | --- | --- | --- | | `request.invalid` | 400 | invalid_request_error | Any validation failure: a missing or wrong-length Idempotency-Key, a malformed or wrong-kind id, an unparseable cursor, metadata that is not an object or is over 4 KB — and, importantly, an unknown query parameter or body field. Validation runs with `forbidNonWhitelisted`, so `?contry=JP` fails loudly instead of being ignored. | `param` names the offending field; on a validation failure `details` carries every message. Fix the request. Do not retry it unchanged. | | `plan.inactive` | 400 | invalid_request_error | You ordered a plan that still resolves but is no longer on sale. | Delist it on your side. `GET /plans/{id}` reports the same condition as `available: false`. | | `auth.missing` | 401 | authentication_error | No `Authorization: Bearer` header. | Send the key. | | `auth.invalid_key` | 401 | authentication_error | The key is malformed, unknown, or revoked. One code for all three, deliberately: three distinguishable answers would let somebody working through a list of stolen keys find out which ones exist. | Check the credential. Retrying will not help. | | `auth.key_expired` | 401 | authentication_error | The key's `expires_at` has passed. The one authentication failure that is distinguishable, because the fix is unguessable from “invalid”. | Switch to the replacement, which was issued at the moment rotation began — you already have it. | | `balance.insufficient` | 402 | insufficient_balance | The conditional debit matched no row for want of funds. Also raised by the sandbox fixture plan `pl_sandbox_insufficient_balance`, whatever the sandbox balance is. | Top up, then retry with a NEW Idempotency-Key — the old one is bound to this refusal permanently. Prevent it with `GET /plans/{id}/price`, which reports `sufficient_balance` and spends nothing. | | `auth.ip_not_allowed` | 403 | authentication_error | The key carries a CIDR allowlist and the request's trusted source address was outside it — or could not be established at all. Both produce the same message on purpose: the fix is identical, and a distinguishable answer would tell a caller which header to experiment with. | Add your egress address to the key, or clear the allowlist. | | `partner.not_live` | 403 | permission_error | A live key on an account that has not been approved for live yet. | Wait for approval. Your sandbox key keeps working throughout. | | `partner.suspended` | 403 | permission_error | Live key, account suspended, balance frozen. | Contact support. Sandbox keys keep working while an account is suspended — a commercial dispute must not also break your build. | | `partner.closed` | 403 | permission_error | The account is closed. Both modes refuse. | The relationship is over. | | `forbidden` | 403 | permission_error | A SANDBOX key on `POST /deposits`. Deposits are a live-mode operation — sandbox spending comes off the play-money float, which is refilled on request, so there is no invoice for a sandbox key to open. The body carries `mode: "sandbox"`. | Send the live key. There is nothing to test here: a sandbox deposit would be real cryptocurrency against imaginary float. If you see this WITHOUT `mode` in the body, or on any other route, report it with the request id — that form does mean a throw site forgot to name itself. | | `plan.not_found` | 404 | not_found_error | No plan with that id is visible to this key: it never existed, it is a sandbox fixture seen by a live key, or it sits in a withdrawn destination. | Re-sync your catalogue mirror. | | `order.not_found` | 404 | not_found_error | No order with that id for this partner AND this mode. A live key addressing a sandbox order gets this rather than a 403 — a distinguishable answer would be an existence oracle. | Check the id, and check which mode's key you sent. | | `esim.not_found` | 404 | not_found_error | No profile with that ICCID in this key's scope. | Check the ICCID and the mode. | | `not_found` | 404 | not_found_error | No deposit with that id on this account, from `GET /deposits/{id}` — which is also what a SANDBOX key receives for every id, because the sandbox writes no deposits. | Check the id, and check which mode’s key you sent. If the path itself is wrong you will get this too, so check the URL before assuming the deposit is missing. | | `idempotency.key_reused` | 409 | idempotency_error | The same Idempotency-Key arrived with a different request fingerprint, or was already used by a key in the other mode. Silently replaying would hand back a profile for a plan you did not ask for the second time. | Use a new key, or resend the original body. Keys are scoped per partner and span both modes, so sandbox and live must not share one. | | `order.in_progress` | 409 | conflict_error | Reserved for order cancellation. | Nothing — no route raises it. | | `order.not_cancellable` | 409 | conflict_error | Reserved for order cancellation. | Nothing — no route raises it. | | `conflict` | 409 | conflict_error | A concurrent re-drive lost a uniqueness race while recording a movement against an order: “A has already been recorded against that order. Nothing was moved.” | Treat it as benign and poll the order. Nothing moved. | | `plan.unavailable` | 422 | unavailable_error | The plan cannot be priced for your account. Either the floor pushed your price above our own retail price — which happens when a plan’s supplier cost has risen since it was last repriced — or the catalogue row has no usable price at all. `GET /plans/{id}/price` gives the vague form because it holds only the boolean; `POST /orders` names which. | Not your fault and not retryable on a timer. Skip the plan. If the order path says the row is not priced, quote the plan id to support. | | `rate_limit.exceeded` | 429 | rate_limit_error | Either the per-key window for the route’s rate class, or the per-address failed-authentication budget. They are the same code and they are not the same thing — see the note below. | Back off. Honour `Retry-After` if it is present; if it is not, you hit the authentication budget, so wait a minute and fix the credential rather than retrying it. | | `internal_error` | 500 | api_error | Our fault. The body is deliberately bare. It is also written to `failure_code` on an order that could never be opened after five attempts. | Retry the SAME Idempotency-Key — that is what it is for. Quote the `request_id` if you contact support. | | `supplier_error` | 502 | supplier_error | Almost always the `failure_code` on an order that failed or partially failed at the supplier, after that order was already accepted with a 202 — no route raises it deliberately. It can still arrive as an HTTP body, because it is what the filter maps a bare 502 to: an upstream failure nothing on the partner path named. | Read it off the order object. If one arrives as an HTTP 502 instead, treat it as a transient upstream failure — retry with the SAME Idempotency-Key, and quote the request id if it persists. | | `service_unavailable` | 503 | api_error | `POST /deposits` could not open an invoice — the payment provider is unconfigured or it refused the request. Nothing was charged, no invoice exists, and your float is untouched. | Retry with backoff. `retryable: true` marks the form where the provider refused a request we expect to succeed later; without it, the rail is down rather than busy, so ask us for bank transfer details instead of looping. This is the reason a deposit client needs retry logic at all — do not skip it. | Codes with no throw site today, listed because they are published and a client may switch on them: `order.in_progress`, `order.not_cancellable`. Some codes are also what a BARE framework exception becomes: nothing on the partner path named a code, so the filter picks one by status. That is why a client switching on `code` never meets a value this list does not carry — `request.invalid` (400), `auth.invalid_key` (401), `balance.insufficient` (402), `forbidden` (403), `not_found` (404), `conflict` (409), `plan.unavailable` (422), `rate_limit.exceeded` (429), `internal_error` (500), `supplier_error` (502), `service_unavailable` (503). An order that failed *after* acceptance carries a `failure_code` drawn from the same vocabulary — `supplier_error` or `internal_error` — so no second vocabulary is needed. Anchors: `#request-invalid`, `#plan-inactive`, `#auth-missing`, `#auth-invalid_key`, `#auth-key_expired`, `#balance-insufficient`, `#auth-ip_not_allowed`, `#partner-not_live`, `#partner-suspended`, `#partner-closed`, `#forbidden`, `#plan-not_found`, `#order-not_found`, `#esim-not_found`, `#not_found`, `#idempotency-key_reused`, `#order-in_progress`, `#order-not_cancellable`, `#conflict`, `#plan-unavailable`, `#rate_limit-exceeded`, `#internal_error`, `#supplier_error`, `#service_unavailable`. ## Sandbox Same base URL, same routes, same request and response shapes. Only the credential differs. The float starts at $1,000 and is refillable on request; it is not money and appears in no financial figure. Four fixture plans, all priced identically at $19.90 list, 5 GB, 30 days: - `pl_sandbox_insufficient_balance` — A 402 that binds its idempotency key. Always refused with 402 balance.insufficient whatever your sandbox balance is, and refused AFTER the order row is inserted — so the idempotency key binds to the refusal exactly as a real 402 does. This is how you test the rule that a topped-up partner must retry with a new key. - `pl_sandbox_supplier_error` — Total failure after acceptance, and the automatic refund. Accepted with 202, then fails provisioning. The order reaches `failed` with failure_code "supplier_error" and the whole amount is back on the balance. It settles on the first pass rather than spending the five-attempt budget — a failure we were asked to produce will not succeed on the fourth try. - `pl_sandbox_slow` — A run that outlives your HTTP client. Completes after 45 seconds, chosen against the 30-second default timeout in most HTTP clients. It is how you prove your retry is safe before a real retry double-charges someone. - `pl_sandbox_partial` — Some units delivered, some refunded. Delivers every unit but the last. At quantity 3, two profiles arrive and one does not, so the order settles `partial` with a one-unit refund. At quantity 1 the single unit IS the last one, so the order fails outright — there is no partial delivery of one profile. Any other plan ordered with a sandbox key provisions normally after 1.5 seconds — deliberately not instantly, so an integration cannot ship having never handled `processing`. The slow fixture takes 45 seconds, chosen against the 30-second default timeout in most HTTP clients. Synthetic profiles: ICCID `89999…`, 19 digits and Luhn-valid, in an ITU-reserved private-network range; SM-DP+ `sandbox.simway.pro`, a host we own that can issue nothing, so a sandbox code pasted into a real handset fails at DNS; matching id prefixed `SB-`; a real `LPA:` activation code and a real scannable QR PNG. How sandbox differs from live, in full: 1. No supplier is called and no real money moves. 2. Spend comes off the sandbox float. 3. `GET /ledger` is **always empty** — sandbox writes no statement rows, so statement and reconciliation code cannot be exercised here. 4. `balance_after_cents` on a later read of a sandbox order is the current float, not the balance after that order. 5. The sandbox rate band applies regardless of tier. 6. The fixture plans are visible; a live key can never see or order them. 7. Sandbox keys keep working while an account is suspended. 8. The forced-failure fixture settles on the first pass instead of spending the 5-attempt budget. 9. Pricing is identical code on real-looking numbers. 10. Fixture plans carry no country code and never appear in `GET /destinations`. ## Not implemented — do not plan around these Each is absent, not pending. An assistant answering questions about this API should say so plainly rather than infer a roadmap. - **Webhooks of any kind.** No delivery model, no signing, no retry ladder, no subscription endpoint. Polling is the only delivery mechanism. - **Order cancellation.** No endpoint. The `cancelled` status is never written, and `order.in_progress` / `order.not_cancellable` have no throw site. - **A refunds API.** Money returns only through automatic settlement of undelivered units. - **Card deposits.** Cryptocurrency (`POST /deposits`) or bank transfer only, and cards are absent by decision rather than backlog: a chargeback lands months after the float has been spent on profiles bought at wholesale and installed on real handsets, and there is no chargeback handler on the card rail at all. The consumer shop's Telegram Stars and cryptocurrency top-ups stop at $50 and are a different rail entirely — they are not partner deposits. - **Key management ENDPOINTS, or self-serve signup.** Keys are issued and revoked by a signed-in human in the cabinet at https://simway.pro/partners/app, never by an API call — a credential that could mint its own successor would survive its own revocation. Opening a partner account, and adding a person to one, are still done by a human at SimWay. There is no cabinet route that can place an order, either: ordering is what keys are for. - **A live usage read, or batch usage.** All usage comes from our own rows and can be up to 30 minutes stale. This is why the `supplier` rate class exists with published limits and no route in it. - **Suspend, unsuspend or top up an existing profile.** A profile runs its course; more data is a new order. - **A hosted QR image URL.** `qr_code_data_url` (a base64 data URL) and `apple_universal_link` are what ship. - **A statement CSV export.** `GET /ledger` is JSON and pages. - **A minimum resale price.** `min_resale_price_cents` is null on every plan; partners set their own retail price. - **A white-label storefront.** The partner builds the front end. SimWay hosts no reseller shop. - **A published SLA or uptime history.** Neither exists yet. `GET /health` is unauthenticated and unmetered so a partner can measure it themselves. ## Changelog ### 2026-08-19 — The partner cabinet - **note** NOTHING ABOUT THE /partner/v1 CONTRACT CHANGED. No route was added, removed, renamed or re-shaped, no field moved, and no rate class was re-priced. An integration written against v1 needs no change, and this entry exists because the previous one told you there was no self-serve key management — which is no longer true, and a changelog that quietly leaves a superseded claim standing is worse than one with no entry at all. - **added** A cabinet for humans, at /partners/app. Members of a partner account sign in with an ordinary SimWay login — email, Google or Telegram — and can read the float, the statement with its running balance, the effective rate and the tier ladder, every order and every eSIM profile; mint, label, list and revoke their own API keys with an IP allowlist; open a deposit; refill the sandbox float; and correct their company details. It is a browser client on the consumer /api/v1 prefix behind a JWT, not a second way to authenticate against /partner/v1. - **added** Two membership roles, fixed by the schema. An owner mints and revokes LIVE keys, opens deposits and edits the company record. A developer mints and revokes SANDBOX keys and reads the money, the orders and the profiles. Two things are owner-only: the live key list, which carries each credential’s label, last four characters and the address it was last used from, and the activation payload on a LIVE profile, because an activation code installs a profile once and is the article being sold. The sandbox returns a full payload to either role, so a developer can build the whole delivery path without it. Being added to an account is still done by a human at SimWay. - **note** The cabinet CANNOT PLACE AN ORDER, deliberately and permanently. Ordering is what an API key is for: a key is chosen, scoped to one mode, pinnable to a CIDR range, revocable in one request and rate-limited on its own bucket, and a browser session is none of those things — so a cabinet that could buy would make a stolen tab a spending instrument. POST /partner/v1/orders also requires an Idempotency-Key because a retried purchase is a double charge, and a browser form has no client that knows to send one. - **note** Key issuance and revocation remain absent from the machine surface and will stay absent. A credential that could mint its own successor would survive its own revocation, which is the whole reason the primitives live behind a signed-in human instead. ### 2026-08-18 — v1 published - **added** The sixteen endpoints of the v1 surface, at https://api.simway.pro/partner/v1 — account, deposits, catalogue, orders and eSIM profiles. - **added** Self-serve deposits: POST /deposits mints a cryptocurrency invoice for a whole number of dollars from $100 to $10,000, GET /deposits is the funding history and GET /deposits/{id} resolves one. It answers 201 rather than the order endpoint’s 202 — an invoice is not a credit — and it deliberately takes no Idempotency-Key, because a repeated POST mints a second invoice and an unpaid invoice is not a charge. Bank transfer remains the rail for anything larger. Cards are absent by decision. - **note** Three deposit behaviours are counter-intuitive and are documented rather than left to be discovered: an overpayment is credited in full rather than capped at the invoice, an underpayment is credited at what actually arrived, and a refund issued after a deposit was credited does NOT take the float back automatically — it is reconciled by a person, and the float may already be spent. - **added** This reference, generated from a typed description of the contract, together with /partners/llms.txt, /partners/llms-full.txt and /partners/openapi.json — all four from the same source. - **note** No webhooks, no order cancellation, no card deposits, no live usage read and no self-serve key management. Each of those is absent rather than pending, and none should be planned around. ## Pages - [eSIM reseller API](https://simway.pro/partners) — Buy eSIM data plans programmatically at a partner price and sell them under your own brand. Prepaid balance, published volume tiers, a real sandbox, and a catalogue with the carrier named per destination. - [Partner pricing](https://simway.pro/partners/pricing) — The volume tier ladder, what the discount is actually worth on a real plan, why a hard price floor caps it at 20%, and how deposits work today. - [Quickstart](https://simway.pro/partners/docs) — Six requests from an empty terminal to an installable eSIM profile, with the two rules that stop an integration going wrong later. - [Authentication](https://simway.pro/partners/docs/authentication) — Bearer keys, the live and sandbox modes, how a key is stored and rotated, IP allowlists and exactly what they are worth. - [Idempotency](https://simway.pro/partners/docs/idempotency) — The Idempotency-Key contract, the four things a replay can do, and the rule that a 402 binds its key permanently. - [Orders and the lifecycle](https://simway.pro/partners/docs/orders) — Why ordering answers 202, the polling loop, what partial delivery means, when refunded money lands, and the three order endpoints in full. - [Catalogue](https://simway.pro/partners/docs/catalogue) — Listing plans, resolving a stored plan id, the price dry run and the destination list — with the incremental-sync caveat. - [eSIM profiles](https://simway.pro/partners/docs/esims) — The install payload, what each activation field is for, usage counters and how stale they can be. - [Account, balance and statement](https://simway.pro/partners/docs/account) — Health, identity, prepaid balance, the statement and the deposit rail — including what happens when a payment arrives over, under, or after the account was closed. - [Errors](https://simway.pro/partners/docs/errors) — Every error code, its status, what caused it and what to do. This page is what the doc_url in every error body points at. - [Rate limits](https://simway.pro/partners/docs/rate-limits) — Four rate classes, the limits per tier, the response headers to pace yourself with, and the two different 429s. - [Sandbox](https://simway.pro/partners/docs/sandbox) — A real environment with a real balance, four fixture plans that force the failures you cannot otherwise rehearse, and the ten ways it differs from live. - [Going live](https://simway.pro/partners/docs/go-live) — What approval involves, how a live key is issued, how deposits work today, and the checklist worth passing before you switch. - [Changelog](https://simway.pro/partners/docs/changelog) — Every change to the contract, newest first, and the rule for which parts of a response are safe to depend on. - [After a deposit](https://simway.pro/partners/billing) — Where a paid invoice returns to. What the redirect does and does not mean, when the float actually moves, and the two places — your cabinet, or your own backend — that can tell you whether it has. - [Machine-readable index](https://simway.pro/partners/llms.txt) - [OpenAPI 3.1](https://simway.pro/partners/openapi.json) - [Consumer catalogue brief](https://simway.pro/llms.txt) — the same catalogue from the traveller's side, including the full per-destination carrier table - [Terms](https://simway.pro/terms) The catalogue is re-synced every 30 minutes and every figure in this document was read from it when the document was generated. Maximum order quantity: 50 profiles. Maximum `metadata`: 4096 bytes.