Hyperscale docs Sign in Start building

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.

Base URL

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:

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:

npm i -g @hyperscale0/cli
hyperscale auth login             # a new founder signs up in the browser
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.

OpenAPI lists every route a CLI login or an agent token can call under the "Founder login" tag, such as /v1/company-snapshots, /v1/bank-statements, /v1/reconciliation-exceptions and /v1/agent-activity. Each description ends with the callers and the scope they need. Statement import, snapshot create and exception resolve and reopen reach every Product, so an agent runs them only on a grant from hyperscale agent setup --all-products. Team, billing, personal access token, agent grant and approval routes act for a person in the browser, and OpenAPI leaves them out.

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.

A founder can sign up over HTTP with no browser. The mailbox is the proof:

  1. POST /v1/identity/signup/request with email, password, displayName, teamName and termsAccepted: true answers 202 verification_sent and emails a six-digit code. The code works for 15 minutes.
  2. POST /v1/identity/signup/verify with email, code and the same password answers 201 with the new user and company and a credential: an agent access token, its refresh token and expiresIn, the same pair the CLI's device login gets. Refresh it at /v1/oauth/token with client hyperscale-cli.

A wrong code or password answers 401 invalid_credentials with attemptsRemaining. The fifth wrong try retires the code with 429 identity_rate_limited. While a code is waiting, a request for the address with another password answers 429 too. Each address and each client IP get three codes an hour. POST /v1/identity/signup with a teamName, no inviteToken and no Turnstile token takes the first step too.

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".

Rehearsal

Send X-Hyperscale-Rehearsal: true on any mutation to run it with no effect. The answer is 200 with outcome (succeeded, refused or approval_would_be_raised), the status the real call would answer, its output or error, the receipt body and the ledger postings. Nothing is written, and the same Idempotency-Key then runs for real. A rehearsal needs a signed-in caller, and identity operations (sign-in, password reset, sessions) refuse it with invalid_request and reason rehearsal_not_offered. With KEY and ACCT from the walk below, this rehearses the account funding:

curl -sS -X POST https://hyperscale0.ai/v1/sandbox/account-funding \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" -H "X-Hyperscale-Rehearsal: true" \
  --data '{"destinationAccountId":"'$ACCT'","amount":"400000","currency":"SAR"}' |
  jq '{outcome, status, postings}'

The OpenAPI documents declare the header on every mutation and the answer as the OperationRehearsal schema. The SDK sends it for a call with { rehearse: true } and types that call's answer as OperationRehearsal.

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. The planDigest composer.plan returns covers those fees, so product.recompose refuses with plan_stale once they move. /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:

{
  "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"]
        }
      ]
    },
    "trace": {
      "gate": "request",
      "law": "A request matches its operation's published schema.",
      "facts": {},
      "remedy": { "kind": "change_request" }
    }
  },
  "requestId": "ee15e7b6-898a-47c6-a9f5-37f976b7f93a"
}

Branch on code, never on message. The SDK types name the codes each operation can return, and so does each OpenAPI operation's x-error-codes. The document's 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.

trace says why in a form a program can act on:

  • gate: the admission gate that refused, one of request, identity, scope, principal, grant_caps, reason, approval, idempotency, product_state, instrument_state, funds, provider and engine, in the order a call meets them.
  • law: the rule that gate applied, in one sentence.
  • facts: what the gate saw, such as the account and the shortfall, or the cap and the amount. Facts never carry a credential or key material.
  • remedy: what would let the call pass. kind is call (run operation with fields first), change_request (send this call with fields changed), retry (send it again under the same key), needs_human (who must act, by operation when one exists) or none. values fills the fields the facts already know: an idempotency_conflict names operation.retrieve with the operationId that ran under the key.

Each code's x-errors entry carries its gate, law and remedy. An engine trace means no law refused: the engine could not give a durable answer, and requestId finds the cause.

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.

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

GET /v1/company/graph pages its open records the same way. Every page carries the whole company graph, and its digest changes only when the company does.

Treat cursors as opaque.

Files

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

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. Both write one format: eleven metadata rows naming the company, account, currency, period range and cutoff, then the column header and the lines.

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; /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. 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:

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:

program desk "Desk"
currency SAR

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:

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:

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:

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:

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:

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:

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":

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:

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.