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.


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.

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

MethodGET only. Anything else returns 405.
EncodingUTF-8 JSON. Requests take no body.
TimestampsISO-8601, always UTC — 2026-08-14T06:30:37.756Z
CachingResponses are no-store. Do not cache them.
Reference formatNUM- 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.

ParameterValuesDefaultNotes
statuspending · paid · expired · fulfilled · allallAnything else → 400
updatedSinceISO-8601 timestamp—Returns orders changed at or after this moment. See §6.
limit1–20050Out-of-range values are clamped; non-numeric falls back to 50
cursoropaque string from nextCursor—See §5. Malformed → 400
linkyour 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"
}
FieldTypeMeaning
referencestringStable unique id. This is your join key.
statusstringpending · paid · expired · fulfilled — see §7
linkstringWhich payment link it came through
partnerstring \nullDisplay name shown to the buyer and on the receipt
destinationstring \nullPlan description, derived from the amount paid
amount.currencystringLowercase ISO-4217 — idr, usd
amount.baseMinorint \nullPrice before the processing fee
amount.feeMinorint \nullThe processing fee
amount.totalMinorint \nullWhat the customer was asked to pay
amount.paidMinorint \nullWhat was actually taken. null until paid
amount.feePercentnumberFee rate applied, e.g. 3.5
amount.displayobjectFormatted for humans. Never parse these — see §4.1
createdAtstringWhen checkout was opened
paidAtstring \nullWhen payment was confirmed
fulfilledAtstring \nullWhen the eSIM was issued
fulfilmentstring \nullFree-text note from our staff, often the ICCID
updatedAtstringLast 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,500 is 31050000, not 310500. 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:

minorhuman
baseMinor30000000Rp 300,000
feeMinor1050000Rp 10,500
totalMinor31050000Rp 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:


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:

  1. Keep a high-water mark of the newest updatedAt you have processed.
  2. Query with a small overlap — subtract 60 seconds from the mark. Cheap insurance against clock skew between systems.
  3. Deduplicate by reference; the overlap means you will re-see a few orders.
  4. Advance the mark to the newest updatedAt in 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
StatusMeansWhat you do
pendingCheckout is open and still payable. No money has been taken.Nothing yet. Do not treat as a sale.
expiredThe buyer did not pay within 3 hours and the checkout closed.Nothing. It cannot become paid.
paidPayment confirmed by the processor. eSIM not yet issued.This is the actionable state.
fulfilledeSIM 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" }

CodeMeaningWhat to do
400Bad parameter — the message says whichFix the request. Do not retry unchanged.
401Missing, unknown, or revoked keyDo not retry. Check the header format, then contact us.
403Key not scoped to the link requestedDrop the link parameter.
404No such reference, or outside your scopeTreat as "not found".
405Method other than GETUse GET.
429Rate limit exceededBack off, then retry. See §9.
500Our side failedRetry 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


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.