# RoamWorks Partner API v1

**Operator:** RoamWorks Limited · 漫行有限公司 · Business Registration No. 81414783  
**Registered office:** Unit 1603, 16/F, The L. Plaza, 367-375 Queen's Road Central, Sheung Wan, Hong Kong  
**Production API base:** `https://roamworks.tech/v1`  
**Sandbox API base:** `https://sandbox.roamworks.tech/v1`  
**Production dashboard:** `https://partners.roamworks.io/partners`  
**Sandbox dashboard:** `https://sandbox.partners.roamworks.io/partners`  
**Status:** Production and sandbox. Production wallet funding is enabled through Stripe Checkout; sandbox funding is disabled. Direct purchase, saved methods, partial fulfilment and physical SIM remain gated.

## 1. What this API is

RoamWorks is a local-eSIM wholesale platform. An approved partner can discover every eligible single-country local eSIM across the live catalogue, obtain a five-minute quote, pay from a prepaid USD/EUR/GBP wallet, and redeem the purchased eSIM as LPA text and/or a QR code.

The API is designed for server-to-server integrations and authorised agents. It is not a public shopping API.

## 2. Capabilities

`GET /capabilities` is public and returns the current flags. Always read it before building a workflow.

| Capability | v1 |
|---|---|
| Local eSIM catalogue | Yes |
| Quotes | Yes |
| Multi-currency wallet | Yes |
| Wallet purchase | Yes |
| Stripe Checkout funding | Gated |
| Direct Stripe purchase | Gated |
| Redemption | Yes |
| Partner webhooks | Yes |
| Partial fulfilment | Gated |
| Usage/progress/traffic reset | No; explicit unavailable |
| Physical SIM | No; early access only |

## 3. Currencies

Prices and wallet balances use `USD`, `EUR` or `GBP`. Amounts are integer minor units (cents/pence). The catalogue is priced in USD and converted with the latest FX snapshot; a stale or missing EUR/GBP rate returns `503 fx_unavailable` for that currency.

The partner wallet has separate balances per currency. An order is paid only from the matching currency; the API never auto-converts wallet balances.

## 4. Authentication and signing

Every `/v1` commerce request, except `/capabilities` and `/sim-interest`, requires HMAC-SHA256.

### Headers

```text
X-Partner-Key-Id: rw_sandbox_...
X-Partner-Timestamp: 1793251200
X-Partner-Nonce: <unique random value>
X-Partner-Signature: <lowercase hex HMAC-SHA256>
Idempotency-Key: <required on mutations>
```

### Canonical string

Join these fields with LF (`\n`), with no trailing LF:

```text
RW-HMAC-SHA256-V1
<key-id>
<environment: sandbox|live>
<unix-seconds>
<nonce>
<UPPERCASE-METHOD>
<lowercase-authority>
<path-and-query-as-sent>
<Idempotency-Key or empty>
<lowercase-hex SHA-256 of the raw request body>
```

Then compute `HMAC-SHA256(secret, canonical)` and send lowercase hex.

- Timestamp skew: 5 minutes maximum.
- Nonce: unique for 10 minutes per key; replay returns `401 replayed`.
- Empty body: SHA-256 of zero bytes.
- Do not reorder or decode/re-encode the query string after signing.
- The signing secret is shown once at key creation. Store it in a secret manager.

### Minimal Node example

```js
import { createHash, createHmac, randomBytes } from 'node:crypto';

const keyId = process.env.RW_KEY_ID;
const secret = process.env.RW_SECRET;
const body = JSON.stringify({ currency: 'USD', items: [{ offerId: 'OFFER_UUID', quantity: 1 }] });
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = randomBytes(16).toString('hex');
const path = '/v1/quotes';
const idempotency = 'quote-2026-001';
const bodyHash = createHash('sha256').update(body).digest('hex');
const canonical = [
  'RW-HMAC-SHA256-V1', keyId, 'sandbox', timestamp, nonce,
  'POST', 'roamworks.tech', path, idempotency, bodyHash
].join('\n');
const signature = createHmac('sha256', secret).update(canonical).digest('hex');

const res = await fetch(`https://roamworks.tech${path}`, {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-partner-key-id': keyId,
    'x-partner-timestamp': timestamp,
    'x-partner-nonce': nonce,
    'x-partner-signature': signature,
    'idempotency-key': idempotency
  },
  body
});
console.log(res.status, await res.text());
```

## 5. Rate limits

Limits are per second, shared across API replicas, and combine partner, key, IP and endpoint scopes. Defaults:

| Scope | Sustained | Burst |
|---|---:|---:|
| Partner total | 20 rps | 40 |
| Per key | 10 rps | 20 |
| Per source IP | 30 rps | 60 |
| Catalogue | 5 rps | 10 |
| Create order | 2 rps | 5 |
| Redeem | 5 rps | 10 |
| Queries | 10 rps | 20 |
| Global partner API | 100 rps | 200 |

A throttled response is `429` with `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. Retry with exponential backoff and jitter; never retry a mutation with a new idempotency key unless the first attempt definitively failed.

## 6. Catalogue

`GET /v1/catalog?currency=USD`

Returns every eligible local offer:

- exactly one country of coverage;
- active listing in a live store;
- approved supply, positive USD cost;
- current price at or above the 20% contribution-margin floor;
- deduplicated by supply-product UUID.

`offerId` is opaque and stable. The response deliberately omits source store, source SKU, supplier, cost and floor.

```json
{
  "currency": "USD",
  "offers": [
    {
      "offerId": "11111111-1111-4111-8111-111111111111",
      "name": "Thailand 15GB 7 days",
      "network": "dtac",
      "validityDays": 7,
      "destination": "TH",
      "available": true,
      "prices": { "USD": 899, "EUR": 829, "GBP": 719 },
      "discount": {
        "requestedPercent": 10,
        "appliedPercent": 10,
        "amount": 90,
        "finalAmount": 809,
        "capped": false
      }
    }
  ]
}
```

Availability is advisory. The purchase rechecks stock and price. No stock is reserved by a catalogue read.

## 7. Quotes

`POST /v1/quotes`

```json
{
  "currency": "USD",
  "items": [{ "offerId": "OFFER_UUID", "quantity": 2 }]
}
```

- Quantity total: 1–500.
- Quote lifetime: 5 minutes.
- The response binds source, list/discount/final unit amounts, FX snapshot, pricing version, permitted payment modes and expiry.
- `capped: true` means the requested discount would breach the floor and the final amount was raised to the floor.
- Use `GET /v1/quotes/{id}` to inspect the quote.

The quote is not a payment. It becomes an order only after a successful purchase.

## 8. Purchase from wallet

`POST /v1/orders`

```json
{
  "quoteId": "QUOTE_UUID",
  "reference": "partner-order-2026-001"
}
```

Required header: `Idempotency-Key`.

The server:

1. checks the partner, quote, expiry and reference;
2. rechecks source, price and stock;
3. locks the wallet;
4. verifies available credit in the quote currency;
5. creates the parent purchase and per-store child orders;
6. reserves eSIM codes and marks the units fulfilled;
7. debits the wallet and writes one ledger entry;
8. returns the purchase and child order ids.

The request is atomic: insufficient funds, a price change, or missing stock creates no order and no debit.

Response example:

```json
{
  "purchaseId": "PURCHASE_UUID",
  "status": "completed",
  "amountMinor": 1618,
  "currency": "USD",
  "orderIds": ["ORDER_UUID"],
  "balanceAfterMinor": 8382
}
```

Use the purchase id for status and redemption. `reference` is unique per partner/environment; replaying the same reference returns the original purchase.

## 9. Orders

- `GET /v1/orders` — latest 50 purchase operations.
- `GET /v1/orders/{purchaseId}` — purchase plus child orders.
- `POST /v1/orders/{purchaseId}/cancel` — only before fulfilment (not yet enabled in sandbox; returns a capability error).
- Child order status: `paid`, `fulfilled`, `fulfillment_failed`, `cancelled`, `refunded`.

No API response contains supplier cost, supplier SKU or margin floor.

## 10. Redemption

`POST /v1/orders/{purchaseId}/redeem`

```json
{ "format": "both" }
```

- Formats: `qr`, `lpa`, `both`.
- Maximum 20 eSIMs per batch.
- `Idempotency-Key` is required; a retry returns the same fixed batch, not the next 20.
- The response contains the batch id, selected item ids, ICCID, LPA text when requested, and QR SVG when requested.
- Credentials are bearer secrets. Do not log, cache, email or forward them outside the entitled partner system.
- Every reveal is audited.

```json
{
  "batchId": "BATCH_UUID",
  "format": "both",
  "items": [
    {
      "orderItemId": "ITEM_UUID",
      "esimCodeId": "CODE_UUID",
      "iccid": "8985...",
      "lpa": "LPA:1$..."
    }
  ],
  "qrSvgs": ["<svg ...>"]
}
```

## 11. Wallet

`GET /v1/wallet`

```json
{
  "currencyMode": "native",
  "wallet": [
    { "currency": "USD", "postedMinor": 10000, "heldMinor": 0, "availableMinor": 10000 },
    { "currency": "EUR", "postedMinor": 0, "heldMinor": 0, "availableMinor": 0 },
    { "currency": "GBP", "postedMinor": 0, "heldMinor": 0, "availableMinor": 0 }
  ]
}
```

Wallet funding through Stripe is enabled on the production host: `POST /v1/wallet/funding` creates a hosted Checkout session, and the signed webhook credits the wallet exactly once. Sandbox funding is disabled and sandbox credit is added by RoamWorks staff. The API never receives card details.

### Webhooks

Partners can configure one HTTPS webhook in the dashboard. RoamWorks sends signed `order.completed` and `esim.redeemed` events.

Header: `x-roamworks-signature: t=<unix>,v1=<hex HMAC-SHA256 of timestamp + "." + raw body>`. Verify the signature against the raw body and reject timestamps older than five minutes. Deliveries retry with backoff up to ten attempts; the event log is visible in the dashboard. Never include LPA/QR in a webhook payload.

### Stripe funding hook

`POST /v1/wallet/funding` requires `wallet:fund` and `Idempotency-Key`, with `{ "amountMinor": 10000, "currency": "USD" }`. It returns a hosted Checkout URL. The signed Stripe webhook credits the wallet exactly once for a paid session. Direct purchase and saved payment methods remain disabled.

## 12. Errors

Errors use:

```json
{ "error": { "code": "out_of_stock", "message": "At least one offer is no longer available." } }
```

| HTTP | Code | Meaning |
|---:|---|---|
| 400 | `validation_error` | Malformed or missing input |
| 401 | `unauthorized` | Missing/unknown credential |
| 401 | `signature_invalid` | HMAC mismatch |
| 401 | `request_expired` | Timestamp outside 5 minutes |
| 401 | `replayed` | Nonce already used |
| 402 | `insufficient_funds` | Not enough available wallet credit |
| 403 | `forbidden` | Key lacks scope or partner disabled |
| 404 | `order_not_found` | Not owned or absent |
| 409 | `out_of_stock` | No sellable stock |
| 409 | `price_changed` | Quote no longer matches |
| 409 | `quote_expired` | Quote lifetime ended |
| 409 | `not_redeemable` | Not fulfilled or no credentials |
| 429 | `rate_limited` | Per-second limit exceeded |
| 500 | `internal_error` | Safe to retry with the same idempotency key |
| 501 | `capability_unavailable` | Feature is deliberately gated |
| 503 | `fx_unavailable` | EUR/GBP rate missing or stale |

## 13. Security

- TLS 1.2+; HSTS; Cloudflare WAF.
- HMAC-SHA256, nonce replay protection, 5-minute timestamp window.
- Secrets encrypted at rest with key-version metadata; only the API can decrypt.
- Scoped keys, optional CIDR allowlists, immediate revocation.
- No secrets, LPA codes or full email addresses in logs.
- Private dashboard pages and API responses are `no-store`; QR/LPA requires partner ownership.
- Rate limits and body-size limits apply before expensive work.
- Report a security issue to `security@roamworks.io`; do not open a public issue with credentials.

## 14. Sandbox

- Base URL is the same; keys start with `rw_sandbox_`.
- Sandbox stock, wallets and suppliers are isolated from live.
- No real money and no real supplier order.
- Create a sandbox key in the partner dashboard or ask RoamWorks support.
- Use `X-Partner-Key-Id` plus HMAC; never send the secret as a bearer token.

## 15. Agent instructions

An agent must:

1. read `/capabilities`;
2. request a quote;
3. verify currency, total, expiry and `capped`;
4. obtain explicit human approval if its mandate does not already cover the amount and currency;
5. create the order with a stable idempotency key and partner reference;
6. never retry a successful or uncertain mutation with a new key;
7. redeem only into an entitled, secure channel;
8. never log or expose LPA/QR credentials.

Raw agent index: `/llms.txt`. Full corpus: `/llms-full.txt`. OpenAPI: `/openapi/v1.json`.

## 16. Migration from another provider

| another provider | RoamWorks v1 |
|---|---|
| `merchantId` + SHA1 `encStr` | Key id + HMAC-SHA256 + timestamp + nonce |
| Quotation endpoint | `GET /catalog` + `POST /quotes` |
| eSIM order (max 500) | `POST /orders` (max 500) |
| Order callback | signed partner webhook (gated) + `GET /orders/{id}` |
| Order + redeem (max 20) | `POST /orders/{id}/redeem` (max 20) |
| Activation/progress/usage | gated; `capability_unavailable` |
| SIM order/top-up | early access only; no endpoint |

## 17. Support

- Partner support: `partners@roamworks.io`
- Security: `security@roamworks.io`
- Company: RoamWorks Limited · 漫行有限公司 · BRN 81414783 · Unit 1603, 16/F, The L. Plaza, 367-375 Queen's Road Central, Sheung Wan, Hong Kong
