# 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

| Value | Example shape | Where it goes |
| --- | --- | --- |
| API ID | `rw_sandbox_xxxxxxxxxxxx` | `X-Partner-Key-Id` header |
| Secret | Shown once at key creation | Never transmitted; used to compute the HMAC |
| Environment | `sandbox` or `live` | Inside 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

```http
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:

```text
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

```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 ?? "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

```python
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:

| Endpoint | Required scope |
| --- | --- |
| `GET /v1/catalog` | `catalog:read` |
| `POST /v1/quotes` | `catalog:read` (current) / `quotes:write` (target) |
| `GET /v1/quotes?id=` (preview) | `catalog:read` |
| `POST /v1/orders` | `orders:write` |
| `GET /v1/orders` | `orders:read` |
| `GET /v1/orders/{id}` | `orders:read` |
| `POST /v1/orders/{id}/redeem` | `esims:redeem` |
| `GET /v1/wallet` | `wallet:read` |
| `POST /v1/wallet/funding` | `wallet:fund` |
| `GET /v1/agents/me` | `catalog:read` |
| `GET /v1/usage`, `GET /v1/activity` | `catalog:read` |
| Agent management paths | `agents: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](/docs/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

| Response | Meaning | Fix |
| --- | --- | --- |
| `401 missing_signature` | One or more signing headers absent | Send all four signing headers. |
| `401 signature_invalid` | HMAC mismatch | Check secret, environment, authority, raw path and body hash. |
| `401 request_expired` | Timestamp outside the window | Sync time and retry with a fresh timestamp and nonce. |
| `401 replayed` | Nonce reused | Generate a new nonce. Do not reuse the whole header set. |
| `401 key_expired` | Key passed its expiry | Rotate or issue a new key. |
| `401 agent_inactive` | Agent is suspended, revoked or expired | Ask the partner owner to reactivate or reissue. |
| `403 ip_not_allowed` | Source IP outside the key allowlist | Use an allowed egress address. |
| `403 forbidden` | Key lacks the endpoint scope | Ask 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](/docs/errors).
