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:
POST /v1/identity/signup/requestwithemail,password,displayName,teamNameandtermsAccepted: trueanswers 202verification_sentand emails a six-digit code. The code works for 15 minutes.POST /v1/identity/signup/verifywithemail,codeand the samepasswordanswers 201 with the new user and company and acredential: an agent access token, its refresh token andexpiresIn, the same pair the CLI's device login gets. Refresh it at/v1/oauth/tokenwith clienthyperscale-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 ofrequest,identity,scope,principal,grant_caps,reason,approval,idempotency,product_state,instrument_state,funds,providerandengine, 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.kindiscall(runoperationwithfieldsfirst),change_request(send this call withfieldschanged),retry(send it again under the same key),needs_human(whomust act, byoperationwhen one exists) ornone.valuesfills the fields the facts already know: anidempotency_conflictnamesoperation.retrievewith theoperationIdthat 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.