# Hyperscale HTTP API

Every Hyperscale Product runs on one HTTP API. This page covers what every call
shares, then walks one customer through money with curl. The full contract,
with a schema and an example for each route, is
[/openapi.json](https://hyperscale0.ai/openapi.json).

## Base URL

```text
https://hyperscale0.ai/v1
```

Sandbox and live share the host. The `X-Hyperscale-Environment` header picks
one: `sandbox` (the default) or `live`. A sandbox key only works in sandbox.

## Authentication

Send a Product key as a Bearer token:

```sh
curl https://hyperscale0.ai/v1/accounts?productId=$PRD \
  -H "Authorization: Bearer $KEY"
```

A Product key starts with `hyperscale_` and acts for one Product. It cannot make
Products or keys. A signed-in founder does that once, with the CLI or in the
browser:

```sh
npm i -g @hyperscale0/cli
hyperscale auth signup            # or: hyperscale auth login
hyperscale product create --source desk.hsx --config desk.json --name "Desk & Key"
hyperscale key create --json | jq -r .shown_once   # the secret, shown once
```

The same steps are HTTP routes under the "Start" tag in OpenAPI. They take a
founder token (a CLI login, an agent token or a personal access token), not a
Product key. Each operation's `x-principal` names the credential it takes.

A key lasts until you revoke it, wherever you made it. A key made from a CLI
login or an agent token keeps working after that login ends. It cannot run
operations no agent may run, such as creating a webhook endpoint. `key create`
lists those in `browserKeyOnlyOperationIds`, and OpenAPI marks them "Needs a
Product key made in the browser".

A revoked key gets 401 `key_revoked` with the date.
A string that is not a key gets 401 `invalid_credentials`.

## Idempotency

Every route marked `x-idempotency: required` needs an `Idempotency-Key` header.
Use a new UUID per intended change.

- The same key and the same body return the first result, with
  `idempotent-replayed: true`. Nothing runs twice.
- The same key with a different body gets 409 `idempotency_conflict`.
- After a timeout or a 5xx, money may have moved. Retry with the same key and
  body. Never send a new key to "try again".

## Money

Amounts are strings in minor units: `"15000"` is SAR 150.00. They match
`^[1-9][0-9]{0,17}$`. A number, a decimal point or a sign is refused with
`invalid_request`. Every amount sits next to its `currency`.

Prices are amounts too. The fees and usage rates on `product.retrieve`,
`product.activation.retrieve`, `composer.plan`, `catalog.pricing.retrieve`,
`product.margin.retrieve` and the rate on each `usage.statement` line are
halalas: a `monthly` of `"250000"` is SAR 2,500.00 a month, and a rate
`amount` of `"25"` is SAR 0.25 per event. A fee can be `"0"`, and a rate agreed
with sales reads `"custom"`. `bps` is basis points, a ratio and not money.
`expectedPricing` takes the halalas `composer.plan` returned.
`/openapi.json` does not list `catalog.pricing.retrieve`, the platform price
list; `hyperscale pricing` prints it.

## Errors

A refusal returns a 4xx or 5xx with one envelope:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Request failed contract validation",
    "details": {
      "issues": [
        {
          "code": "invalid_format",
          "message": "Invalid string: must match pattern /^[1-9][0-9]{0,17}$/",
          "path": ["amount"]
        }
      ]
    }
  },
  "requestId": "ee15e7b6-898a-47c6-a9f5-37f976b7f93a"
}
```

Branch on `code`, never on `message`. Each operation's `x-error-codes` lists
the codes it can return. The document-level `x-errors` gives each code's
status, cause, fix and whether a retry can succeed (`retryable`). A 4xx moved
no money. Quote `requestId` when you ask for help.

`csrf_token_invalid` only reaches a write made with a browser session cookie.
A call with a Bearer token never gets it.

## Responses

A retrieve answers with the object itself: `GET /v1/customers/$CUS` returns
`{"customerId": "cus_…", …}`. A list answers `{"items": […], "nextCursor": …}`.
A write answers with what it made or changed, and its receipt id sits in the
`hyperscale-receipt-id` header.

## Pagination

List routes take `limit` (1 to 100) and `cursor`. A response with
more items carries `nextCursor`. Pass it back as `cursor` until it is absent.

```sh
curl "https://hyperscale0.ai/v1/customers?productId=$PRD&limit=100&cursor=$NEXT" \
  -H "Authorization: Bearer $KEY"
```

Treat cursors as opaque.

## Files

A statement export answers with the file itself, so `curl -o` saves it:

```sh
curl -o statement.csv \
  "https://hyperscale0.ai/v1/accounts/$ACCOUNT/statements/2026-10/export" \
  -H "Authorization: Bearer $KEY"
```

The response is `text/csv` with a `Content-Disposition` filename and a
`Content-Digest` SHA-256. Send `Accept: application/json` to get a JSON object
with the CSV in `csv`, plus `filename`, `sha256` and `sizeBytes`. The SDK asks
for JSON. A stored company statement
(`/v1/statement-exports/{id}/download`) answers the same way, with the CSV in
`content`.

## Rate limits

Each key or session gets 600 requests per 60-second window. Each IP gets
1,200. Every response carries:

| Header                  | Meaning                                 |
| ----------------------- | --------------------------------------- |
| `x-ratelimit-limit`     | Requests allowed in the window          |
| `x-ratelimit-remaining` | Requests left in the window             |
| `x-ratelimit-reset`     | When the window resets, in Unix seconds |
| `retry-after`           | On a 429 only: seconds to wait          |

A 429 is `rate_limited` and is retryable: wait `retry-after` seconds, then
resend with the same `Idempotency-Key`.

## Versions

`/v1` in the path is the version. Within `/v1` these changes ship without
notice:

- New routes, new optional request fields, new response fields.
- New error codes, new enum values in responses, new event types.

These count as breaking and never ship under `/v1`:

- Removing or renaming a route, field or error code.
- Making an optional request field required, or changing a field's type or
  units.
- Changing what a status or error code means.

A breaking change ships under a new prefix, and `/v1` keeps running beside it.
Every release republishes [/openapi.json](https://hyperscale0.ai/openapi.json);
[/release.json](https://hyperscale0.ai/release.json) names the release. Diff
the spec to see what changed. Write clients that ignore fields they do not
know.

## Webhooks

Webhooks follow [Standard Webhooks](https://www.standardwebhooks.com). Each
delivery carries `webhook-id`, `webhook-timestamp` (Unix seconds) and
`webhook-signature`. The signature is `v1,` and a base64 HMAC-SHA256 of
`{id}.{timestamp}.{raw body}`, keyed with the base64-decoded part of your
`whsec_` secret after the prefix. While a secret rotates, the header carries
both signatures, separated by a space.

Verify against the raw body, before you parse it:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();
  return headers["webhook-signature"].split(" ").some((entry) => {
    const [version, signature = ""] = entry.split(",");
    const given = Buffer.from(signature, "base64");
    return (
      version === "v1" &&
      given.length === expected.length &&
      timingSafeEqual(given, expected)
    );
  });
}
```

The `standardwebhooks` package on npm does the same:
`new Webhook(secret).verify(rawBody, headers)`.

## Walk: one customer through money

This walk uses a Product with one monthly membership:

```hsx
program desk "Desk"
currency SAR
use money

object membership "Desk membership" {
  fields { desk: text }
  attach dues = money.schedule {
    payer: owner, payee: operator, amount: 900 SAR, every: 1 month
    economics occurrence.pay { purpose: earning, sourceParty: payer }
    expose create as start_membership
    expose activate as accept_membership
  }
}
```

Save it as `desk.hsx`, write `{"partyBindings":{}}` to `desk.json`, and run
the four start commands above. Then set the key, the Product and a helper. It
needs `curl`, `jq` and `uuidgen`:

```sh
export KEY=hyperscale_...       # shown_once from hyperscale key create
export PRD=prd_sandbox_...      # from hyperscale product create
hs() {
  curl -sS -X "$1" "https://hyperscale0.ai/v1$2" \
    -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
    -H "Idempotency-Key: $(uuidgen)" ${3:+--data "$3"}
}
```

Create a test customer, open an account for it and fund it with SAR 4,000.00:

```sh
CUS=$(hs POST /customers '{"productId":"'$PRD'","test":true,
  "entity":{"kind":"person","displayName":"Noura Alharbi","country":"SA"}}' |
  jq -r .customerId)
ACCT=$(hs POST /accounts '{"productId":"'$PRD'","role":"customer_balance",
  "currency":"SAR","owner":{"type":"customer","id":"'$CUS'"}}' |
  jq -r .accountId)
hs POST /sandbox/account-funding \
  '{"destinationAccountId":"'$ACCT'","amount":"400000","currency":"SAR"}'
```

Create a membership the customer owns:

```sh
OBJ=$(hs POST /products/$PRD/objects/membership \
  '{"fields":{"desk":"D-12"},"onBehalfOf":"'$CUS'"}' | jq -r .objectId)
```

An action call names the kind, the object and the action. The platform reads
the revision, target, Build and digest from the object as it stands. Send
`expectedRevision` when you want a 409 if someone changed the object since you
read it. This function runs one action, as the operator or for a customer:

```sh
act() { # act ACTION [CUSTOMER] [INPUT_JSON]
  hs POST "/products/$PRD/objects/membership/$OBJ/actions/$1" "$(
    jq -cn --arg who "${2:-}" --argjson input "${3:-null}" '
      (if $who == "" then {} else {onBehalfOf: $who} end)
      + (if $input == null then {} else {input: $input} end)')"
}
```

Each action answers with the object, an `outcome` and a `receiptId`.
`outcome.moves` lists every ledger transfer the action made, with its
`transferId`, `amount` and `status`. A payment the Product's clock collects
runs as its own operation; the action names it in the
`hyperscale-clock-operation-ids` header.

The operator starts the membership five minutes from the Product's clock, and
the customer accepts it:

```sh
NOW=$(hs GET /products/$PRD/sandbox-clock | jq -r .effectiveAt)
START=$(jq -rn --arg now "$NOW" '$now | sub("\\.[0-9]+Z$"; "Z") | fromdate + 300 | todate')
act start_membership "" '{"startsAt":"'$START'"}'
act accept_membership $CUS
```

Move the sandbox clock a day on. The first SAR 900.00 of dues is collected:

```sh
AT=$(jq -rn --arg now "$NOW" '$now | sub("\\.[0-9]+Z$"; "Z") | fromdate + 86400 | todate')
hs POST /sandbox/clock/advance '{"productId":"'$PRD'","at":"'$AT'"}' | jq .ran
```

Read the balance. SAR 4,000.00 less SAR 900.00 leaves `"310000"`:

```sh
hs GET /accounts/$ACCT/balance | jq '{available, currency}'
```

Read the books. One call gives the cash position, revenue to date and each
month's earnings:

```sh
hs GET /products/$PRD/books | jq '{position, revenueToDate, months}'
```

`revenueToDate` is `"90000"`, and the month's row carries `"revenue": "90000"`.
A key reaches the reads its Product's Build had when it was made, so a Product
last built before r341 gets the books route on its next Build.
