# 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](https://sandbox.partners.roamworks.io/partners). 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](/docs/ip-allowlist).

:::danger[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
[security@roamworks.io](mailto:security@roamworks.io).
:::

## 1. Store the credentials

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

```bash
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](/docs/authentication).

```ts
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.

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

A sandbox response looks like this:

```json
{
  "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

```ts
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](/docs/catalogue).

## 5. Create a quote

```ts
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](/docs/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

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

### One-call

```ts
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](/docs/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

```ts
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.

:::danger[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

| Symptom | Likely cause | Action |
| --- | --- | --- |
| `401 signature_invalid` | Wrong secret, canonical string mismatch, or body changed after signing | Recompute over the exact raw body and path used on the wire. |
| `401 request_expired` | Server clock is more than five minutes from the API | Sync time with NTP and recompute the timestamp. |
| `401 replayed` | Nonce reused within ten minutes | Generate a fresh nonce per attempt, including retries. |
| `403 ip_not_allowed` | Egress IP is outside the key allowlist | Use an allowed address or ask the owner to update the allowlist. |
| `402 insufficient_funds` | Wallet available balance is below the quote total | Fund the wallet or reduce the order. Do not retry blindly. The target `payment_required` shape adds `funding_url`. |
| `409 quote_expired` | More than 15 minutes passed | Request a new quote. Do not reuse the expired one. |
| `429 rate_limited` | Per-minute limit or daily quota reached | Honour `Retry-After`, add jitter, and reduce concurrency. |

## Next

- [Authentication](/docs/authentication) for the exact canonical string and more helpers.
- [Orders](/docs/orders) for statuses and idempotency rules.
- [Wallet](/docs/wallet) for funding and balance semantics.
- <a href="/docs/agent-prompt.md">Agent prompt</a> if an autonomous agent will run this flow.
