Sign in to get a key
Technical documentation

Partner API

Your customer pays on your system → one call to us → the SIM is handed over at the airport → a webhook comes back with the real serial. Base URL: https://app.simpickup.com/api/partner/v1

1 · Authentication and IP locking

Every call carries the API key in a header:

Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxx

The key is shown once, at creation. Lost it? Issue another — ten seconds. An API key can reach nothing outside /api/partner/v1/*.

Keep the key server-side. Putting it in browser or mobile-app code hands it to everyone — anyone who opens devtools can read it.

IP locking — recommended

Register your server IPs in the admin area and the key works only from those addresses. A leaked key is useless from anywhere else.

203.0.113.7          a single host
203.0.113.0/24       a whole range

Calling from an unregistered IP returns 403 including the IP that just called, so you know what to register. Changed servers? Edit the list — no need for a new key.

2 · Two environments

sk_test_…sk_live_…
SIM stockseparate, fake SIMs, refillableyour real stock
Ordersseparate, NOBODY goes to the airportthe handover WILL happen
Month-end reconciliationnot countedcounted
Mark as delivered yourselfyes (section 10)no

These are two separate datastores, not a flag. A sandbox order never leaks into the live list, and vice versa.

One base URL for both. Going from sandbox to live means changing the key, not the URL — so there is nothing left to forget on launch day.

3 · Create an order

A sandbox key starts with EMPTY stock. Call POST /api/partner/v1/sandbox/restock once first, or the example below returns 409 INSUFFICIENT_STOCK. See section 10.
curl -X POST https://app.simpickup.com/api/partner/v1/orders \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "partnerRef":  "WEB-2026-00123",
    "airportCode": "SGN",
    "carrierCode": "vinaphone",
    "qty":         2,
    "flightNo":    "VJ862",
    "meetAt":      "2026-09-25T08:00:00+07:00",
    "guestName":   "Kim Min-jun",
    "guestEmail":  "kim@example.com"
  }'
fieldrequirednotes
partnerRef✅Your own order id. This is what prevents duplicates — see section 4
airportCode✅SGN, HAN, DAD… from GET /catalog
carrierCode✅vinaphone, local… from GET /catalog
qty✅Number of SIMs, 1–50
flightNo✅Flight number
meetAt✅Meeting time. ISO 8601 WITH OFFSET — see section 7
guestName✅Traveller name as printed in the passport
guestEmailIf present, SimPickup emails the QR code to the traveller
sendGuestEmailSet false if YOU send the QR code in your own email
arrivalAt, terminal, pax, noteOptional
Do not send SIM serials. The system picks and reserves one from your stock. The traveller buys a plan, not a particular card — and your system does not need to keep a mirror of our inventory.

Response

{
  "partnerRef": "WEB-2026-00123",
  "pickupCode": "ZRT79ZTAUQYV",
  "status":     "PENDING",
  "airportCode":"SGN",
  "meetAt":     "2026-09-25T01:00:00.000Z",
  "qty": 2, "deliveredQty": 0,
  "serials": ["8984020001185123945", "8984020001185123896"],
  "guestEmailQueued": true,
  "replay": false
}

pickupCode is what the traveller shows at the airport. If you email travellers yourself, render it as a QR pointing to https://app.simpickup.com/p/<pickupCode>

Out of stock

HTTP 409
{ "error": "INSUFFICIENT_STOCK", "requested": 5, "available": 2,
  "message": "..." }

No SIM is reserved. Check GET /stock (section 8) so you know before you sell.

4 · Retries must not create extra orders

Your payment webhook will retry — network timeouts, processes dying mid-flight. partnerRef is what keeps one payment to one order.

caseresult
new reference201 + new order
same reference, same payload200 + THE SAME order, "replay": true
same reference, different payload409 PARTNER_REF_CONFLICT

Resend freely whenever you are unsure. Never mint a new reference for a payment that already has one — that is the only way to create a duplicate.

5 · Getting told when the SIM is handed over

Set your webhook URL in the admin area. Every time an order changes state we send:

{
  "event":  "order.delivered",
  "sentAt": "2026-09-25T01:12:00.000Z",
  "data": {
    "partnerRef": "WEB-2026-00123",
    "pickupCode": "ZRT79ZTAUQYV",
    "status": "DELIVERED",
    "qty": 2, "deliveredQty": 2,
    "deliveredAt": "2026-09-25T01:11:58.000Z",
    "serials": ["8984...", "8984..."]
  }
}
eventmeaning
order.deliveredThe traveller has the SIM
order.partialFewer handed over than ordered — see deliveredQty and reason
order.cancelledOrder cancelled, SIMs back in stock
order.no_showThe traveller never showed up

Verify the signature — required

X-SimPickup-Signature: t=1758765120,v1=6b8a…,v1=91cd…

HMAC-SHA256 over the string "{t}.{raw body}" using the secret we issue you.

const crypto = require('node:crypto');

function valid(header, rawBody, secret) {
  const p = {};
  for (const kv of header.split(',')) {
    const [k, v] = kv.trim().split('=');
    if (k && v) (p[k] ??= []).push(v);
  }
  const t = Number(p.t?.[0]);
  // Older than 5 minutes: reject. Stops someone replaying a captured call.
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;

  const want = crypto.createHmac('sha256', secret)
    .update(`${t}.${rawBody}`).digest();

  return (p.v1 ?? []).some((g) => {
    const b = Buffer.from(g, 'hex');
    return b.length === want.length && crypto.timingSafeEqual(b, want);
  });
}
Sign the raw body, not an object you JSON.parsed and stringifyd again — key order changes and the signature breaks.

There may be two v1 values. When you change your webhook URL, messages are signed with both the old and new secret for 24 hours so you drop nothing. Matching either is valid.

How to respond

Return 2xx immediately and process afterwards — we wait at most 10 seconds. Anything other than 2xx and we retry a few times with backoff. Processing the same message twice must give the same result — retries are normal.

6 · A way out when the webhook dies

Do not depend on the webhook alone. Your server goes down, a certificate expires, a domain changes — the message never lands, and neither side knows the two of you have diverged.

GET https://app.simpickup.com/api/partner/v1/orders?updatedSince=2026-09-25T00:00:00Z&limit=100

{ "items": [ … ], "nextSince": "2026-09-25T01:11:58.000Z" }

Store nextSince and pass it back as updatedSince next time. Run it every few minutes and no outage, however long, loses you an order.

7 · Meeting time must carry an offset

"meetAt": "2026-09-25T08:00:00+07:00"   ✅
"meetAt": "2026-09-25T01:00:00Z"        ✅
"meetAt": "2026-09-25T08:00:00"         ❌ HTTP 400

Without an offset nobody knows which country's 08:00 you mean. We refuse rather than guess — guessing right 99 times and wrong once means one traveller standing at an airport with nobody there, and that one failure is indistinguishable from the other ninety-nine.

8 · Check stock before you sell

GET https://app.simpickup.com/api/partner/v1/stock

{ "items": [ { "airportCode": "SGN", "carrierCode": "vinaphone", "available": 93 } ] }

This is the count not already reserved for an order. Skip this step and you only learn you are out of stock after taking the customer's money.

9 · The remaining endpoints

GET  /api/partner/v1/orders/{partnerRef}          fetch one order
POST /api/partner/v1/orders/{partnerRef}/cancel   refunded → release the SIM
GET  /api/partner/v1/catalog                      airports + carriers

Cancelling an order whose SIM already reached the traveller returns 409 NOT_CANCELLABLE — that one has to be settled outside the system.

10 · A full dry run

With a sk_test_ key only:

POST /api/partner/v1/sandbox/restock                        refill sandbox stock
POST /api/partner/v1/orders/{partnerRef}/sandbox-deliver    mark as delivered yourself

restock tops stock up to 20 SIMs per airport × carrier. It tops up to a ceiling rather than adding, so you can sell out and refill as often as you like — handy for exercising the INSUFFICIENT_STOCK path.

sandbox-deliver goes through the same handover code as a real delivery, so what you test is what will run live. It fires the webhook exactly as a real handover does.

11 · Limits and error codes

120 calls per minute per key → 429 with Retry-After.

codemeaning
400Bad body — missing field, wrong format, meetAt without an offset
401Key wrong, missing, or revoked
403Called the internal API by mistake · sandbox call with a live key · IP_NOT_ALLOWED
404No order with that reference WITHIN YOUR SCOPE
409INSUFFICIENT_STOCK · PARTNER_REF_CONFLICT · NOT_CANCELLABLE
429Rate limited
Error message text is currently Vietnamese. The error code and the HTTP status are stable and language-independent — branch on those, not on the message.

12 · The order to do things in

  1. Issue an sk_test_ key in the admin area and register your server IPs
  2. Set a sandbox webhook URL and copy the signing secret
  3. POST /sandbox/restock → GET /stock to see airport and carrier codes
  4. POST /orders once; check partnerRef and pickupCode come back right
  5. Repeat the exact same call — you must get "replay": true and no second order
  6. sandbox-deliver → check your webhook endpoint and verify the signature
  7. Oversell on purpose → check you handle INSUFFICIENT_STOCK properly
  8. Build the updatedSince polling loop and schedule it
  9. Issue an sk_live_ key + register your production IPs — no code changes

Stuck anywhere? Tell us the partnerRef and the time of the call — we can find that exact request in the log. admin@simpickup.com