Num — Offline Orders API v1
Read-only HTTP API for checking the status of eSIM orders paid through a Num payment link.
Use it to answer: has this been paid for, and has the eSIM been issued yet?
This API is read-only. GET is the only method it accepts; POST, PUT, PATCH and DELETE all return 405. There is no endpoint here to create, change, refund, cancel or fulfil an order, and your key grants nothing outside this one URL.
- Base URL —
https://numesim.uk/v1/offline-orders - Contact — [email protected]
- Version — v1. Fields may be added without notice; nothing will be removed or renamed without us telling you first.
1. Authentication
Every request carries a bearer key:
Authorization: Bearer numk_live_xxxxxxxxxxxxxxxxxxxxxxxx
Your key is sent to you separately — never by email in plain text, and it is not written in this document.
This key is issued to [email protected], who is responsible for it. It is not tied to a login: there is no account to sign into, no password, and no Num dashboard access. The key is the credential, so whoever holds the string can read your orders.
The key is a credential. Treat it like a password.
- Server-side only. Never put it in browser JavaScript, a mobile app, or anything a customer can view — a key shipped to a client is a public key.
- Never commit it to git. Use an environment variable or a secrets manager.
- CORS is disabled, so a browser cannot call this API directly even if it has the key. This is deliberate.
Your key is scoped: it returns only orders paid through your own payment link. Orders from other links are invisible to it, not merely forbidden.
If a key leaks, tell us immediately — we revoke in seconds. To rotate without downtime we issue a second key, you switch, then we retire the first. Keys are stored as hashes, so a lost key is reissued, never recovered.
2. Conventions
| Method | GET only. Anything else returns 405. |
| Encoding | UTF-8 JSON. Requests take no body. |
| Timestamps | ISO-8601, always UTC — 2026-08-14T06:30:37.756Z |
| Caching | Responses are no-store. Do not cache them. |
| Reference format | NUM- followed by 6+ alphanumerics, e.g. NUM-SKIRV7. Case-insensitive on lookup, always returned uppercase. |
3. Endpoints
3.1 List orders
GET /v1/offline-orders
Query parameters — all optional.
| Parameter | Values | Default | Notes |
|---|---|---|---|
status | pending · paid · expired · fulfilled · all | all | Anything else → 400 |
updatedSince | ISO-8601 timestamp | — | Returns orders changed at or after this moment. See §6. |
limit | 1–200 | 50 | Out-of-range values are clamped; non-numeric falls back to 50 |
cursor | opaque string from nextCursor | — | See §5. Malformed → 400 |
link | your link key | — | Not needed. Your key is already scoped; asking for another link returns 403 |
Response
{
"orders": [ { /* order object, see §4 */ } ],
"nextCursor": "MTc4NjY4OTAzNzc1NjpOVU0tU0tJUlY3"
}
Orders are sorted by updatedAt, newest first.
3.2 Fetch one order
GET /v1/offline-orders/NUM-SKIRV7
{ "order": { /* order object */ } }
Returns 404 if the reference does not exist or belongs to a different payment link. These are deliberately indistinguishable.
4. The order object
{
"reference": "NUM-SKIRV7",
"status": "paid",
"link": "venus",
"partner": "Venus corporate eSIM",
"destination": "Indonesia 100GB, 30 days",
"amount": {
"currency": "idr",
"baseMinor": 30000000,
"feeMinor": 1050000,
"totalMinor": 31050000,
"paidMinor": 31050000,
"feePercent": 3.5,
"display": { "base": "Rp300,000", "total": "Rp310,500" }
},
"createdAt": "2026-08-14T06:30:37.756Z",
"paidAt": "2026-08-14T06:31:52.104Z",
"fulfilledAt": null,
"fulfilment": null,
"updatedAt": "2026-08-14T06:31:52.104Z"
}
| Field | Type | Meaning | |
|---|---|---|---|
reference | string | Stable unique id. This is your join key. | |
status | string | pending · paid · expired · fulfilled — see §7 | |
link | string | Which payment link it came through | |
partner | string \ | null | Display name shown to the buyer and on the receipt |
destination | string \ | null | Plan description, derived from the amount paid |
amount.currency | string | Lowercase ISO-4217 — idr, usd | |
amount.baseMinor | int \ | null | Price before the processing fee |
amount.feeMinor | int \ | null | The processing fee |
amount.totalMinor | int \ | null | What the customer was asked to pay |
amount.paidMinor | int \ | null | What was actually taken. null until paid |
amount.feePercent | number | Fee rate applied, e.g. 3.5 | |
amount.display | object | Formatted for humans. Never parse these — see §4.1 | |
createdAt | string | When checkout was opened | |
paidAt | string \ | null | When payment was confirmed |
fulfilledAt | string \ | null | When the eSIM was issued |
fulfilment | string \ | null | Free-text note from our staff, often the ICCID |
updatedAt | string | Last change of any kind. Poll on this — see §6 |
There is no customer email or name in this API. Match orders by reference, which you should capture at the moment of payment.
4.1 Money — read this carefully
All amounts are integers in the currency's minor unit. Never floats.
To get a human amount, divide by 100:
310500 minor -> Rp 3,105 ✗ WRONG
31050000 minor -> Rp 310,500 ✓ CORRECT
⚠️ IDR is a two-decimal currency to the payment processor, even though rupiah subunits are not used in practice.Rp 310,500is31050000, not310500. Getting this wrong is a 100× error in either direction.
feePercent may be 0, in which case baseMinor, totalMinor and paidMinor are all the same figure. Worked example with a 3.5% fee applied:
| minor | human | |
|---|---|---|
baseMinor | 30000000 | Rp 300,000 |
feeMinor | 1050000 | Rp 10,500 |
totalMinor | 31050000 | Rp 310,500 |
The same rule applies to USD, where the minor unit is cents: $45.00 is 4500.
display.base and display.total are pre-formatted strings for showing in a UI. Their format is not part of the contract and may change — never parse them back into numbers.
Reconcile against baseMinor, not totalMinor. The base is the plan price; the total includes the processing fee.
paidMinor is the authority on what was actually charged. It should equal totalMinor. If it does not, do not process the order — contact us.
5. Pagination
Pass limit, then follow nextCursor until it is null.
GET /v1/offline-orders?status=paid&limit=100
-> { "orders": [...100], "nextCursor": "MTc4Nj..." }
GET /v1/offline-orders?status=paid&limit=100&cursor=MTc4Nj...
-> { "orders": [...12], "nextCursor": null }
Two things to handle:
cursormust be passed with the same filters as the first call. ChangingstatusorupdatedSincemid-walk gives undefined results.- The last page may be empty. A cursor is issued whenever a page comes back full, so if the total is an exact multiple of
limityou get one final response with"orders": []and"nextCursor": null. Loop on the cursor, not on the array length.
6. Polling for changes
Poll on updatedSince, never on createdAt.
An order created on Monday may be paid on Friday. Its createdAt stays Monday forever — filtering on creation time would never show you that it was paid, which is the one thing you are watching for. updatedAt moves on every change.
Recommended loop:
- Keep a high-water mark of the newest
updatedAtyou have processed. - Query with a small overlap — subtract 60 seconds from the mark. Cheap insurance against clock skew between systems.
- Deduplicate by
reference; the overlap means you will re-see a few orders. - Advance the mark to the newest
updatedAtin the response.
Suggested interval: every 1–5 minutes. Payment confirmation is typically a few seconds behind the customer completing checkout.
There are no webhooks yet. Ask us if push delivery would help.
7. Order lifecycle
pending ──payment confirmed──> paid ──eSIM issued──> fulfilled
│
└──── 3 hours, unpaid ────> expired
| Status | Means | What you do |
|---|---|---|
pending | Checkout is open and still payable. No money has been taken. | Nothing yet. Do not treat as a sale. |
expired | The buyer did not pay within 3 hours and the checkout closed. | Nothing. It cannot become paid. |
paid | Payment confirmed by the processor. eSIM not yet issued. | This is the actionable state. |
fulfilled | eSIM issued. fulfilment holds our note, often the ICCID. | Order complete. |
Statuses only move forward. expired and paid are terminal opposites — an order reaches one or the other, never both.
pending lasts at most 3 hours. After that the payment window closes and the order becomes expired — reported by the payment processor, not by a timer on our side, so a pending order is genuinely still payable until it flips. Neither state is a sale; filter with ?status=paid unless you want to see abandoned attempts.
Fulfilment is done by a person, so the gap between paid and fulfilled is operational time, not milliseconds.
8. Errors
All errors return JSON: { "error": "human-readable reason" }
| Code | Meaning | What to do |
|---|---|---|
400 | Bad parameter — the message says which | Fix the request. Do not retry unchanged. |
401 | Missing, unknown, or revoked key | Do not retry. Check the header format, then contact us. |
403 | Key not scoped to the link requested | Drop the link parameter. |
404 | No such reference, or outside your scope | Treat as "not found". |
405 | Method other than GET | Use GET. |
429 | Rate limit exceeded | Back off, then retry. See §9. |
500 | Our side failed | Retry with exponential backoff. If it persists, contact us. |
Retry 429 and 5xx. Never blind-retry 4xx — the request itself is wrong.
9. Rate limit
60 requests per minute, per key. Over that returns 429.
This is generous for the intended use — polling every minute with pagination costs a handful of requests. If you need more, talk to us rather than working around it with multiple keys.
On 429, back off exponentially (1s, 2s, 4s…) rather than retrying immediately.
10. Examples
curl
# everything paid but not yet issued
curl -s "https://numesim.uk/v1/offline-orders?status=paid" \
-H "Authorization: Bearer $NUM_API_KEY"
# one order
curl -s "https://numesim.uk/v1/offline-orders/NUM-SKIRV7" \
-H "Authorization: Bearer $NUM_API_KEY"
# anything that changed in the last hour
curl -s "https://numesim.uk/v1/offline-orders?updatedSince=2026-08-14T05:00:00Z" \
-H "Authorization: Bearer $NUM_API_KEY"
Node.js — full polling loop with pagination
const BASE = "https://numesim.uk/v1/offline-orders";
const KEY = process.env.NUM_API_KEY;
async function page(params) {
const res = await fetch(`${BASE}?${new URLSearchParams(params)}`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (res.status === 429) throw new Error("rate limited");
if (!res.ok) throw new Error((await res.json()).error);
return res.json();
}
// Returns every order changed since `since`, following cursors to the end.
async function changedSince(since) {
const params = { status: "paid", updatedSince: since, limit: "100" };
const all = [];
let cursor;
do {
const data = await page(cursor ? { ...params, cursor } : params);
all.push(...data.orders);
cursor = data.nextCursor; // loop on the cursor, not on length
} while (cursor);
return all;
}
let mark = new Date(Date.now() - 86_400_000).toISOString(); // last 24h to start
const seen = new Set();
setInterval(async () => {
try {
// 60s overlap absorbs clock skew; dedupe by reference.
const from = new Date(Date.parse(mark) - 60_000).toISOString();
for (const o of await changedSince(from)) {
if (o.updatedAt > mark) mark = o.updatedAt;
if (seen.has(o.reference)) continue;
seen.add(o.reference);
const rupiah = o.amount.baseMinor / 100; // minor units -> whole currency
console.log(`${o.reference} ${o.destination} Rp${rupiah.toLocaleString()}`);
}
} catch (e) {
console.error("poll failed:", e.message); // next tick retries
}
}, 120_000);
Python
import os, requests
BASE = "https://numesim.uk/v1/offline-orders"
HEADERS = {"Authorization": f"Bearer {os.environ['NUM_API_KEY']}"}
def changed_since(since, status="paid"):
params = {"status": status, "updatedSince": since, "limit": 100}
orders, cursor = [], None
while True:
r = requests.get(BASE, headers=HEADERS,
params={**params, **({"cursor": cursor} if cursor else {})},
timeout=20)
r.raise_for_status()
data = r.json()
orders += data["orders"]
cursor = data["nextCursor"]
if not cursor:
return orders
for o in changed_since("2026-08-14T00:00:00Z"):
print(o["reference"], o["destination"], o["amount"]["baseMinor"] / 100)
PHP
<?php
$base = 'https://numesim.uk/v1/offline-orders';
$ch = curl_init("$base?status=paid");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('NUM_API_KEY')],
]);
$data = json_decode(curl_exec($ch), true);
foreach ($data['orders'] as $o) {
printf("%s %s %s\n", $o['reference'], $o['destination'],
$o['amount']['baseMinor'] / 100);
}
11. Integration checklist
- [ ] Key stored in an environment variable or secrets manager, never in code
- [ ] All calls made server-side
- [ ] Amounts divided by 100 — verified against a real order
- [ ] Reconciling on
baseMinor, nottotalMinor - [ ]
paidMinorcompared tototalMinor; mismatch stops processing - [ ] Polling on
updatedSince, notcreatedAt - [ ] Overlap window and dedupe by
reference - [ ] Cursor loop terminates on
nextCursor === null, not on an empty array - [ ]
429and5xxretried with backoff;4xxnot - [ ] Only
status: "paid"treated as a sale
12. Support
[email protected] — quote the order reference and the UTC timestamp of the request.
Tell us straight away if a key is exposed. Revocation is immediate and a replacement takes minutes.