Documentation

Authentication

Every /v1 commerce request except /v1/capabilities, /v1/agent-manifest.json, /v1/sim-interest, /healthz and the Stripe receiver requires an HMAC-SHA256 signature. There is no bearer token mode. The API ID identifies the key; the secret signs the request.

Credentials

ValueExample shapeWhere it goes
API IDrw_sandbox_xxxxxxxxxxxxX-Partner-Key-Id header
SecretShown once at key creationNever transmitted; used to compute the HMAC
Environmentsandbox or liveInside the canonical string

Store the secret in a secret manager on the server that makes the call. Do not put it in a browser, a mobile app, a repository, a URL, a log, a screenshot or a prompt. The dashboard only shows it at creation. If you lose it, rotate the key.

Required headers

Code
X-Partner-Key-Id: rw_sandbox_xxxxxxxxxxxx X-Partner-Timestamp: 1793251200 X-Partner-Nonce: 8c1f0c1d2e3b4a5c9d6e7f8091a2b3c4 X-Partner-Signature: 5f2c9d...lowercase-hex... Idempotency-Key: order-2026-001

Idempotency-Key is required on POST /v1/orders, POST /v1/orders/{id}/redeem and POST /v1/wallet/funding. If you send it on another request, it is included in the canonical string but does not create deduplication behaviour.

Canonical string

Join these ten fields with a single LF (\n) and no trailing LF:

Code
RW-HMAC-SHA256-V1 <api-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 the result as lowercase hex in X-Partner-Signature.

Rules that matter in practice:

  • Authority is the host only, lowercase and without a port. A call to https://roamworks.tech/v1/quotes signs roamworks.tech; a sandbox call to https://sandbox.roamworks.tech/v1/quotes signs sandbox.roamworks.tech.
  • Path and query must match what you send, byte for byte. Do not reorder, decode or re-encode query parameters after signing.
  • Method is uppercase in the canonical string.
  • Body hash is over the exact raw bytes sent. Serialize JSON once and sign that string.
  • Empty body hashes the zero-length string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
  • Timestamp is Unix seconds. The server accepts a maximum skew of five minutes.
  • Nonce must be unique per key for ten minutes. A valid replay returns 401 replayed.
  • Idempotency key is included even when empty. Use the same value you send in the header.

Node.js helper

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 ?? "roamworks.tech"; export function signRequest(input: { method: string; path: string; body?: string; idempotencyKey?: string; }) { const timestamp = Math.floor(Date.now() / 1000).toString(); const nonce = randomUUID().replaceAll("-", ""); const body = input.body ?? ""; const bodyHash = createHash("sha256").update(body).digest("hex"); const canonical = [ "RW-HMAC-SHA256-V1", API_ID, ENV, timestamp, nonce, input.method.toUpperCase(), HOST, input.path, input.idempotencyKey ?? "", bodyHash, ].join("\n"); return { "x-partner-key-id": API_ID, "x-partner-timestamp": timestamp, "x-partner-nonce": nonce, "x-partner-signature": createHmac("sha256", API_SECRET) .update(canonical) .digest("hex"), ...(input.idempotencyKey ? { "idempotency-key": input.idempotencyKey } : {}), }; }

Python helper

Code
import hashlib import hmac import json import os import time import uuid import urllib.request API_ID = os.environ["RW_API_ID"] API_SECRET = os.environ["RW_API_SECRET"].encode() ENV = os.environ.get("RW_ENV", "sandbox") HOST = os.environ.get("RW_API_HOST", "roamworks.tech") def signed_request(method, path, body=None, idempotency_key=None): raw = "" if body is None else json.dumps(body, separators=(",", ":")) timestamp = str(int(time.time())) nonce = uuid.uuid4().hex body_hash = hashlib.sha256(raw.encode()).hexdigest() canonical = "\n".join( [ "RW-HMAC-SHA256-V1", API_ID, ENV, timestamp, nonce, method.upper(), HOST, path, idempotency_key or "", body_hash, ] ) signature = hmac.new(API_SECRET, canonical.encode(), hashlib.sha256).hexdigest() headers = { "accept": "application/json", "x-partner-key-id": API_ID, "x-partner-timestamp": timestamp, "x-partner-nonce": nonce, "x-partner-signature": signature, } if raw: headers["content-type"] = "application/json" if idempotency_key: headers["idempotency-key"] = idempotency_key request = urllib.request.Request( "https://" + HOST + path, data=raw.encode() if raw else None, headers=headers, method=method.upper(), ) with urllib.request.urlopen(request) as response: return json.loads(response.read())

Scopes

A key carries a set of scopes. A missing scope returns 403 forbidden after the signature is valid. The current handler mapping is:

EndpointRequired scope
GET /v1/catalogcatalog:read
POST /v1/quotescatalog:read (current) / quotes:write (target)
GET /v1/quotes?id= (preview)catalog:read
POST /v1/ordersorders:write
GET /v1/ordersorders:read
GET /v1/orders/{id}orders:read
POST /v1/orders/{id}/redeemesims:redeem
GET /v1/walletwallet:read
POST /v1/wallet/fundingwallet:fund
GET /v1/agents/mecatalog:read
GET /v1/usage, GET /v1/activitycatalog:read
Agent management pathsagents:manage

The published contract assigns quotes:write to quote creation, but the running handler currently checks catalog:read. During rollout, give a key both scopes if it needs quotes. esims:read is an assignable agent scope with no required handler yet. agents:manage is a principal scope: an agent key is always rejected from agent-management paths. See Agent identities.

Key and secret lifecycle

  • Create: the dashboard or POST /v1/agents/{agentId}/keys returns the API ID and secret once. Copy the secret into the server secret manager immediately.
  • Rotate: POST /v1/agents/{agentId}/keys/{keyId}/rotate issues a new key and sets the old key to expire after 24 hours.
  • Expire: keys can have an expiry. Agent keys inherit the agent expiry or default to 90 days.
  • Revoke: DELETE /v1/agents/{agentId} revokes the agent and its keys. A revoked key returns 401 unauthorized.
  • Scope changes: take effect on the next request. There is no token cache to invalidate.

Clock, nonce and replay

Use a synchronised clock. If the host clock drifts more than five minutes, every request fails with 401 request_expired. Generate a fresh nonce for every attempt, including retries of the same idempotent request. A retry may reuse the idempotency key, but never the nonce.

Common signing failures

ResponseMeaningFix
401 missing_signatureOne or more signing headers absentSend all four signing headers.
401 signature_invalidHMAC mismatchCheck secret, environment, authority, raw path and body hash.
401 request_expiredTimestamp outside the windowSync time and retry with a fresh timestamp and nonce.
401 replayedNonce reusedGenerate a new nonce. Do not reuse the whole header set.
401 key_expiredKey passed its expiryRotate or issue a new key.
401 agent_inactiveAgent is suspended, revoked or expiredAsk the partner owner to reactivate or reissue.
403 ip_not_allowedSource IP outside the key allowlistUse an allowed egress address.
403 forbiddenKey lacks the endpoint scopeAsk the owner to grant the scope.

Transport and storage

  • TLS 1.2 or newer. Certificate validation is mandatory; do not disable it.
  • The API sets Cache-Control: private, no-store on /v1 responses.
  • Secrets are encrypted at rest with key-version metadata. Only the API can decrypt them.
  • Do not copy a secret into a support ticket. Share the API ID and request ID instead.

For request-level failures, see Errors.