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
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/*.
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.
| sk_test_… | sk_live_… | |
|---|---|---|
| SIM stock | separate, fake SIMs, refillable | your real stock |
| Orders | separate, NOBODY goes to the airport | the handover WILL happen |
| Month-end reconciliation | not counted | counted |
| Mark as delivered yourself | yes (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.
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"
}'
| field | required | notes |
|---|---|---|
| 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 |
| guestEmail | If present, SimPickup emails the QR code to the traveller | |
| sendGuestEmail | Set false if YOU send the QR code in your own email | |
| arrivalAt, terminal, pax, note | Optional |
{
"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>
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.
Your payment webhook will retry — network timeouts, processes dying mid-flight. partnerRef is what keeps one payment to one order.
| case | result |
|---|---|
| new reference | 201 + new order |
| same reference, same payload | 200 + THE SAME order, "replay": true |
| same reference, different payload | 409 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.
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..."]
}
}
| event | meaning |
|---|---|
| order.delivered | The traveller has the SIM |
| order.partial | Fewer handed over than ordered — see deliveredQty and reason |
| order.cancelled | Order cancelled, SIMs back in stock |
| order.no_show | The traveller never showed up |
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);
});
}
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.
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.
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.
"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.
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.
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.
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.
120 calls per minute per key → 429 with Retry-After.
| code | meaning |
|---|---|
| 400 | Bad body — missing field, wrong format, meetAt without an offset |
| 401 | Key wrong, missing, or revoked |
| 403 | Called the internal API by mistake · sandbox call with a live key · IP_NOT_ALLOWED |
| 404 | No order with that reference WITHIN YOUR SCOPE |
| 409 | INSUFFICIENT_STOCK · PARTNER_REF_CONFLICT · NOT_CANCELLABLE |
| 429 | Rate limited |
Stuck anywhere? Tell us the partnerRef and the time of the call — we can find that exact request in the log. admin@simpickup.com