Documentation

Quickstart

This quickstart takes a sandbox partner from an API ID and secret to a redeemed eSIM. Every commerce request is signed with HMAC-SHA256. The examples use placeholder identifiers only.

Before you start

  • An approved partner account and a sandbox key from the sandbox dashboard. The secret is shown once at creation. Production keys use the production dashboard and the production API.
  • Node.js 20 or newer for the signing helper (any runtime with HMAC-SHA256 and SHA-256 works).
  • The key scopes you need: catalog:read, quotes:write, orders:write, orders:read, wallet:read, esims:redeem.
  • If the key has an IP allowlist, run from an allowed IPv4 or IPv6 address. See IP allowlist.

Keep the secret out of the browser

The API secret must never be placed in a browser bundle, a mobile app, a URL, a log line or a prompt. Sign on your server. If the secret is exposed, revoke the key immediately and contact [email protected].

1. Store the credentials

Create a sandbox key in the dashboard. Store the two values as environment variables:

TerminalCode
export RW_API_ID="rw_sandbox_REPLACE_ME" export RW_API_SECRET="REPLACE_WITH_THE_SECRET_FROM_KEY_CREATION" export RW_ENV="sandbox" export RW_API_HOST="sandbox.roamworks.tech"

The API ID is a public identifier with the rw_sandbox_ prefix in sandbox and rw_live_ in production. The secret is a bearer credential and is never sent directly. The signature proves possession of it.

2. Add the signing helper

Save this as rw-sign.ts. It signs the canonical string described in Authentication.

TypeScriptCode
import { createHash, createHmac, randomUUID } from "node:crypto"; const API_ID = process.env.RW_API_ID!; const API_SECRET = process.env.RW_API_SECRET!; const ENV = process.env.RW_ENV ?? "sandbox"; const HOST = process.env.RW_API_HOST ?? "sandbox.roamworks.tech"; export async function signedRequest( method: string, path: string, body?: unknown, idempotencyKey?: string, ) { if (!API_ID || !API_SECRET) throw new Error("Missing RW_API_ID or RW_API_SECRET"); const rawBody = body === undefined ? "" : JSON.stringify(body); const timestamp = Math.floor(Date.now() / 1000).toString(); const nonce = randomUUID().replaceAll("-", ""); const bodyHash = createHash("sha256").update(rawBody).digest("hex"); const canonical = [ "RW-HMAC-SHA256-V1", API_ID, ENV, timestamp, nonce, method.toUpperCase(), HOST, path, idempotencyKey ?? "", bodyHash, ].join("\n"); const signature = createHmac("sha256", API_SECRET) .update(canonical) .digest("hex"); const headers: Record<string, string> = { accept: "application/json", "x-partner-key-id": API_ID, "x-partner-timestamp": timestamp, "x-partner-nonce": nonce, "x-partner-signature": signature, }; if (rawBody) headers["content-type"] = "application/json"; if (idempotencyKey) headers["idempotency-key"] = idempotencyKey; const response = await fetch("https://" + HOST + path, { method, headers, body: rawBody || undefined, }); const payload = await response.json().catch(() => null); if (!response.ok) { throw new Error( "RoamWorks " + response.status + " " + (payload?.error?.code ?? "unknown"), ); } return payload; }

The signed path must be exactly the path and query you send, preserving order and encoding.

3. Confirm capabilities

GET /v1/capabilities is public and unsigned. Read it before building a workflow so you can see which flags are on.

TerminalCode
curl -s https://sandbox.roamworks.tech/v1/capabilities | jq

A sandbox response looks like this:

JSONCode
{ "api": "v1", "service": "RoamWorks partner", "currencies": ["USD", "EUR", "GBP"], "capabilities": { "localEsimCatalogue": true, "regionalEsimCatalogue": true, "quotes": true, "wallet": true, "stripeCheckoutFunding": false, "directPurchase": true, "bulkOrders": true, "agentIdentities": true, "ipAllowlist": true, "partnerWebhooks": true, "redemption": true, "partialFulfilment": true, "esimUsage": false, "esimProgress": false, "trafficReset": false, "physicalSim": false }, "docs": { "manifest": "/v1/agent-manifest.json", "markdown": "/docs/api.md", "prompt": "/docs/agent-prompt.md", "openapi": "/openapi/v1.json" } }

4. List the catalogue

TypeScriptCode
const catalogue = await signedRequest("GET", "/v1/catalog?currency=USD"); const offer = catalogue.offers.find((item: { available: boolean }) => item.available);

Each offer has an opaque offerId, a display name, destination, validity, data allowance, prices in all three currencies, and an advisory available flag. Availability is not a stock reservation. See Catalogue.

5. Create a quote

TypeScriptCode
const quote = await signedRequest("POST", "/v1/quotes", { currency: "USD", items: [{ offerId: offer.offerId, quantity: 1 }], });

Quotes are valid for 15 minutes. A quote can carry up to 200 lines and 5,000 units in total. Check expiresAt, currency, totalMinor and capped before asking for a payment decision. The quote binds price and FX; it does not move money. See Quotes.

6. Create the order

There are two supported order shapes. Use the quote flow when a human wants to review the total first, or the one-call flow when the agent already has a mandate and an expected ceiling.

Quote-based

TypeScriptCode
const order = await signedRequest( "POST", "/v1/orders", { quoteId: quote.quoteId, reference: "partner-order-2026-001" }, "order-2026-001", );

One-call

TypeScriptCode
const order = await signedRequest( "POST", "/v1/orders", { reference: "partner-order-2026-002", currency: "USD", expectedTotalMinor: 899, items: [{ offerId: offer.offerId, quantity: 1 }], }, "order-2026-002", );

expectedTotalMinor is optional. When present, the API compares it with the freshly computed total and returns 409 price_changed with the new total instead of charging. This is the safest one-call shape for an agent.

The Idempotency-Key is required. Use a new key for each logical order and reuse the same key for retries. Keep reference stable and unique per partner and environment. A successful response contains purchaseId, status, amountMinor, currency, orderIds and balanceAfterMinor.

status can be processing, completed or failed. processing means at least one unit is awaiting upstream fulfilment. Poll GET /v1/orders/{purchaseId} until the status becomes terminal. See Orders.

A 402 insufficient_funds means no order and no debit was created. Top up the wallet, then retry only if you intentionally create a new attempt with the same idempotency key and reference. The target contract adds 402 payment_required with a funding_url; that shape is not live yet.

7. Redeem

TypeScriptCode
const redemption = await signedRequest( "POST", "/v1/orders/" + order.purchaseId + "/redeem", { format: "both" }, "redeem-2026-001", );

Redeem at most 20 eSIMs per batch. A retry with the same key returns the same fixed batch. The response contains batchId, format, items (item, code, ICCID, LPA when requested) and qrSvgs when requested.

LPA and QR are bearer secrets

Deliver redemption output only into the partner's entitled, secure channel. Do not log, cache, email, forward or display LPA or QR data outside that channel. No webhook payload contains LPA or QR data.

Troubleshooting

SymptomLikely causeAction
401 signature_invalidWrong secret, canonical string mismatch, or body changed after signingRecompute over the exact raw body and path used on the wire.
401 request_expiredServer clock is more than five minutes from the APISync time with NTP and recompute the timestamp.
401 replayedNonce reused within ten minutesGenerate a fresh nonce per attempt, including retries.
403 ip_not_allowedEgress IP is outside the key allowlistUse an allowed address or ask the owner to update the allowlist.
402 insufficient_fundsWallet available balance is below the quote totalFund the wallet or reduce the order. Do not retry blindly. The target payment_required shape adds funding_url.
409 quote_expiredMore than 15 minutes passedRequest a new quote. Do not reuse the expired one.
429 rate_limitedPer-minute limit or daily quota reachedHonour Retry-After, add jitter, and reduce concurrency.

Next

  • Authentication for the exact canonical string and more helpers.
  • Orders for statuses and idempotency rules.
  • Wallet for funding and balance semantics.
  • Agent prompt if an autonomous agent will run this flow.