In plain words
The local API is a small server on your own computer that any program can talk to. You start it with one command. Then a script, a bot or an app you write can ask it things like "what is my balance?" or "send 2 USDG to this wallet", in any programming language.
It only answers programs on the same computer, and only when they show a secret password (the token). Your key never leaves your computer.
Nothing moves by accident. When you ask it to send money, it first answers with a plan: the fee, what arrives, what the public can see. It sends only when you ask again and add "confirm": true.
No agent key yet? Start with Getting started. Rather not write code? Use it with Claude or the command line. Writing Node.js? The TypeScript SDK does the same without a server.
All 21 routes at a glance
Every route lives under http://127.0.0.1:8081/v1. GET routes only read. POST routes that move money return a plan first. Click a route for its fields and examples.
| Route | What it does |
|---|---|
GET /v1/health | Is the server running? (no token) |
GET /v1/openapi.json | The full machine-readable description (no token) |
GET /v1/status | Where things stand, without asking the chain |
GET /v1/address | The agent's wallet and its h081-address: code |
GET /v1/balance | Private balance, wallet balance, budgets |
GET /v1/history | What this computer sent |
GET /v1/fees/withdraw | The fee for a withdrawal, before you make it |
POST /v1/deposit | Wallet → private balance |
POST /v1/withdraw | Private balance → any wallet |
POST /v1/pay | Pay from a budget this agent holds |
POST /v1/transfer | Private balance → another H081 account |
POST /v1/allowances | Give a budget |
POST /v1/allowances/{id}/reclaim | Take back what is left of a budget |
GET /v1/loans/quote | Price a loan from a lender's terms |
POST /v1/loans/borrow | Pledge a budget, get a proof code for the lender |
POST /v1/loans/lend | Lender actions: open, fund, collect, claim, settle |
GET /v1/loans/lent | Your loan slots as a lender |
GET /v1/stake | Your H081 stake |
POST /v1/stake | Stake or unstake H081 |
POST /v1/codes/check | Check any h081-* code |
GET /v1/events | Live updates (server-sent events) |
The machine-readable version of everything on this page: h081-agent-openapi.json (OpenAPI 3.1).
Start the server
Start it, and leave it running
Run this in a terminal and keep that window open. It listens on
127.0.0.1:8081, a private address only your own computer can reach. In the background it also downloads the proof files (about 98 MB, once, checked against fixed fingerprints) so the first payment does not wait.terminal 1 · any system npx -y @h081lll/agent servewhat it prints (in the terminal, not to programs) H081 gateway 0.1.0 on http://127.0.0.1:8081/v1 (mainnet-beta, agent 7xKX…, RPC <your RPC host>) token: /home/you/.h081/api-token docs: http://127.0.0.1:8081/v1/openapi.json …Press Ctrl+C to stop it. It uses the key in
~/.h081/agent.jsonand the settings in~/.h081/config.json, both written bynpx -y @h081lll/agent init.Check it answers
Open a second terminal. This route needs no token, so it is a quick way to see the server is up.
terminal 2 · macOS / Linux curl -s http://127.0.0.1:8081/v1/healthterminal 2 · Windows PowerShell Invoke-RestMethod http://127.0.0.1:8081/v1/health | ConvertTo-Json -Depth 5response { "ok": true, "network": "mainnet-beta", "status": "done", "result": { "version": "0.1.0", "network": "mainnet-beta" }, "requestId": "req_3f9c2a71d0b84e15" }Make your first real call
Every other route needs the token. Set up once shows how to read it in each language.
Options
| Flag | Default | What it does |
|---|---|---|
--port <n> | 8081 | The port (1 to 65535). The environment variable H081_API_PORT sets the same thing. |
--poll <seconds> | 20 | How often it re-reads the chain for live events, only while a listener is connected. At least 1. |
--allow-origin <url> | none | Lets exactly one web page call the API from a browser. See Local security. |
npx -y @h081lll/agent serve --port 8082
npx -y @h081lll/agent serve --poll 5
npx -y @h081lll/agent serve --allow-origin http://localhost:5173There is no option to listen on any address other than 127.0.0.1. Asking for one (--host or --bind) is refused.
Set up once: the token
In plain words: the token is a long random password, saved in a file on your computer. Programs send it with every call so the server knows the call comes from you.
- It lives in
~/.h081/api-token(only your user can read it).initandservecreate it if it is missing. - Send it as the header
Authorization: Bearer <token>. - Only
GET /v1/healthandGET /v1/openapi.jsonwork without it.
Pick your language. The examples further down build on these lines: $TOKEN in bash, $h in PowerShell, and the call() helper in Python and TypeScript.
# read the token once per terminal
TOKEN=$(cat ~/.h081/api-token)
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8081/v1/balance# read the token once per window
$token = (Get-Content "$HOME\.h081\api-token" -Raw).Trim()
$h = @{ Authorization = "Bearer $token" }
Invoke-RestMethod -Headers $h http://127.0.0.1:8081/v1/balance# pip install requests
import pathlib, time, uuid, requests
API = "http://127.0.0.1:8081/v1"
s = requests.Session()
s.headers["Authorization"] = "Bearer " + (pathlib.Path.home() / ".h081" / "api-token").read_text().strip()
def call(method, path, body=None, key=None, **kw):
"""One API call. Returns the whole envelope; raises on an error envelope."""
headers = {"Idempotency-Key": key} if key else {}
r = s.request(method, API + path, json=body, headers=headers, **kw).json()
if not r["ok"]:
raise RuntimeError(r["error"]) # code, message, retryable, details
return r
print(call("GET", "/balance")["result"]["shielded"])// h081.mts · Node.js 20+ (an ES module, so top-level await works)
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
const API = 'http://127.0.0.1:8081/v1';
const TOKEN = readFileSync(join(homedir(), '.h081', 'api-token'), 'utf8').trim();
async function call(method: 'GET' | 'POST', path: string, body?: object, key?: string) {
const headers: Record<string, string> = { Authorization: `Bearer ${TOKEN}` };
if (body) headers['Content-Type'] = 'application/json';
if (key) headers['Idempotency-Key'] = key;
const res = await fetch(API + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
const env = await res.json();
if (!env.ok) throw new Error(`${env.error.code}: ${env.error.message}`);
return env;
}
console.log((await call('GET', '/balance')).result.shielded);See or replace the token
npx -y @h081lll/agent tokennpx -y @h081lll/agent token --rotateAfter --rotate, restart serve and update your scripts: the old token stops working. A missing or wrong token gets this answer (HTTP 401):
{ "ok": false, "network": "mainnet-beta",
"error": { "code": "unauthorized", "retryable": false,
"message": "missing or wrong token: send Authorization: Bearer <the token in ~/.h081/api-token>" },
"requestId": "req_…" }What every answer looks like
In plain words: every answer is a small JSON document with the same shape. ok tells you whether it worked. If it did, the data is in result. If not, error says what went wrong and whether trying again can help.
{
"ok": true,
"network": "mainnet-beta",
"status": "done", ← done (a read) · plan (nothing sent) · sent (it went out)
"result": { … }, ← what the route returns
"replayed": true, ← only when an Idempotency-Key replay returned a stored result
"requestId": "req_…" ← also in the X-Request-Id header and the server's log
}{
"ok": false,
"network": "mainnet-beta",
"error": {
"code": "needs_sol", ← a fixed code: switch on this, not on the message
"message": "…", ← for people
"retryable": false, ← true: the same request may work if you wait and try again
"details": { "needSol": "…" }
},
"requestId": "req_…"
}| Field | Meaning |
|---|---|
ok | true or false. |
network | mainnet-beta (the real one), or localnet on a test validator. |
status | done: a read finished. plan: a write was described and nothing was sent. sent: it went out. |
result | What the route returns. Each route below shows it. |
replayed | Only present, as true, when a retry with the same Idempotency-Key got the stored answer back instead of sending again. |
error | code (fixed, for programs), message (for people), retryable, and sometimes details. All codes: Errors and fixes. |
requestId | An id like req_3f9c…, also sent as the X-Request-Id header and written in the server's log line. |
Amounts
Amounts are USDG, written as decimal strings: "usdg": "5.25". Where a route takes usdg, you can give amount in base units instead ("5250000" = 5.25 USDG, 6 decimals), but never both. In results, fields ending in Usdg (like receivedUsdg) are decimal; the same name without it (received) is in base units.
A POST body must be a JSON object, at most 64 KB, sent with Content-Type: application/json. A field a route does not take is refused with bad_request, and the message lists the fields it does take.
Plan first, then confirm
In plain words: asking the server to send money is a two-step conversation. First it tells you exactly what would happen. Then, if you agree, you ask again and say "yes, do it".
Ask without confirm: get the plan
Send the request without
confirm(or with"confirm": false). You get"status": "plan". Nothing is proven, signed or sent, so this is always safe to try.Read the plan
Plan field Meaning op,usdg,toWhat, how much, to whom. feeUSDG taken out of the amount. payoutUSDG the recipient gets. solNeededSOL the agent wallet needs for this (network fees). circuit,downloadWhich proof it makes, and what the first one downloads (once). publicOnChainWhat anyone can see on the blockchain afterwards. sentByrelayer(H081's relayer sends it, so your agent wallet is not the sender) oragent key.warningsThings to know first, in plain sentences. detailsExtra detail for budgets, loans and staking. nextAlways: "Nothing was sent. Send it again with confirm: true (CLI: --yes) to do it." Send it: confirm + Idempotency-Key
Send the same request with
"confirm": trueand a headerIdempotency-Key. Without that header the server refuses (400 idempotency_key_required), so that a retry can never pay twice. The answer is"status": "sent"with the transactionsignature.
Idempotency keys: never pay twice
In plain words: an idempotency key is a label you put on one payment, like an order number. If your program crashes or the network drops and you are not sure the payment went out, you send it again with the same label. The server recognises the label and gives you the first answer back instead of paying a second time.
- Required on every write with
"confirm": true. Header:Idempotency-Key: <key>. - 1 to 128 characters from
A-Z a-z 0-9 _ . : -. A random UUID works well. - One new key for each new payment. Reuse a key only to retry that same payment with the same body.
- Keys are remembered for 7 days in
~/.h081/ops.jsonl(the command line and the server share this file).
| What happens | What the server answers |
|---|---|
| Same key, same body, already finished | The stored answer again, with "replayed": true. Nothing is sent again. |
| Same key, still running | 409 in_progress, header Retry-After: 5. Wait and ask again with the same key. |
| Same key, different body or route | 422 idempotency_key_reused. Use a new key. |
| The server stopped while it ran, or the connection to the relayer or the Solana RPC failed in the middle of sending | 409 outcome_unknown: it may have been sent. The same key keeps answering outcome_unknown and is never sent again. Check GET /v1/balance?fresh=1 before you try again with a new key. |
| A retryable error where nothing was sent (relayer busy, rate limited, a stale proof root) | The key is released: the same request may be sent again with the same key. |
# You already asked without confirm and read the plan. Now send it, safely.
key = str(uuid.uuid4()) # ONE key for this ONE payment
body = {"usdg": "2", "to": "<fresh wallet>", "confirm": True}
for attempt in range(4):
resp = s.post(API + "/withdraw", json=body, headers={"Idempotency-Key": key})
r = resp.json()
if r["ok"] or not r["error"]["retryable"]:
break
time.sleep(int(resp.headers.get("Retry-After", "5"))) # same key: it can never go out twice
if not r["ok"] and r["error"]["code"] == "outcome_unknown":
print("It may have been sent. Check the balance before trying again with a NEW key:")
print(call("GET", "/balance", params={"fresh": "1"})["result"]["shielded"])
else:
print(r)Queue and limits
In plain words: the server does one payment at a time, in order, and refuses to be flooded.
| Limit | Value | When you hit it |
|---|---|---|
| Proofs at once | 1 (one queue: one proof and one send at a time) | Writes wait their turn. |
| Writes waiting | 8 | 503 busy with Retry-After: 5. |
| Sends per minute | 30 (H081_API_MAX_SENDS_PER_MIN) | 429 rate_limited with Retry-After in seconds. |
| Balance cache | 15 seconds | GET /v1/balance answers from memory; add ?fresh=1 to read the chain now. |
| Relayer busy or rate limited | retried once | Only then is the error returned. |
HTTP/1.1 503 Service Unavailable
Retry-After: 5
{ "ok": false, "network": "mainnet-beta",
"error": { "code": "busy", "message": "8 sends are already waiting; try again shortly",
"retryable": true, "details": { "retryAfter": 5 } },
"requestId": "req_…" }Plans and reads do not count toward the send limit; only confirmed writes do. You can also set ceilings, enforced before anything is proven: H081_MAX_DEPOSIT, H081_MAX_PAY, H081_MAX_GRANT and H081_MAX_LEND (base units). Going over gives 409 over_operator_ceiling.
Local security
In plain words: the server only listens to your own computer, and it refuses web pages unless you name one.
- 127.0.0.1 only. It never listens on your network. There is no option to change that.
- Host check. The
Hostheader must be127.0.0.1:<port>orlocalhost:<port>. This blocks a trick called DNS rebinding, where a website pretends to be your own computer. Otherwise:403 forbidden_origin. - Origin check. Browsers add an
Originheader to calls from web pages. Any request with anOriginis refused (403 forbidden_origin) unless it equals--allow-originexactly. No CORS headers are sent otherwise. curl, scripts and the SDK send noOrigin, so they just work. - JSON only. A POST must be
Content-Type: application/json(otherwise415 unsupported_media_type), so a plain web form cannot post to it. - Token. Compared in constant time, so it cannot be guessed by timing.
- Logs. Keys, tokens, notes and full RPC URLs are never logged; only the RPC host is shown.
Calling it from your own web page
Only for a page you wrote and trust, running on your own computer. Start the server with that page's exact origin:
npx -y @h081lll/agent serve --allow-origin http://localhost:5173// in the page served from http://localhost:5173 (and only that exact origin)
const token = '<the token from ~/.h081/api-token>';
const r = await fetch('http://127.0.0.1:8081/v1/balance', { headers: { Authorization: 'Bearer ' + token } });
console.log((await r.json()).result.shielded);The server then answers that origin's preflight requests, allowing GET, POST and the headers Authorization, Content-Type, Idempotency-Key and Last-Event-ID. The page needs the token, so anyone who can change that page can spend.
Reading: status, balance, history, fees
These routes only look. They never send anything.
GET/v1/health
Is the server running? Returns its version and network.
Use it to check the server is up before anything else. No token, no chain access.
curl -s http://127.0.0.1:8081/v1/health{ "ok": true, "network": "mainnet-beta", "status": "done",
"result": { "version": "0.1.0", "network": "mainnet-beta" },
"requestId": "req_3f9c2a71d0b84e15" }GET/v1/openapi.json
The OpenAPI 3.1 description of every route, field and error code.
The raw document, not wrapped in the ok/result envelope. Load it into any OpenAPI tool to browse the API or generate a client. The same file is on this site: /h081-agent-openapi.json.
curl -s http://127.0.0.1:8081/v1/openapi.jsonGET/v1/status
Where things stand: network, agent, key source, RPC host, proof files, queue. No chain call.
Quick and offline: it does not ask the blockchain anything. circuits.missing lists proof files not downloaded yet; queue.depth counts writes waiting or running; fundingNeeded is the SOL to keep in the agent wallet for each kind of use.
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8081/v1/status{ "ok": true, "network": "mainnet-beta", "status": "done",
"result": {
"network": "mainnet-beta",
"agent": "7xKX…",
"keySource": "file /home/you/.h081/agent.json",
"rpcHost": "<your RPC host>",
"relayer": "api.h081.xyz",
"circuits": { "present": ["transact", "spend"], "missing": ["grant", "stream"] },
"syncedAt": null,
"syncing": false,
"queue": { "depth": 0 },
"fundingNeeded": { "deposit": "0.05", "withdraw": "0.05", "allow": "0.15", "lend": "0.15" }
},
"requestId": "req_…" }GET/v1/address
The agent wallet and its h081-address: code. Offline.
wallet is the public Solana address to send SOL and USDG to. addressCode is what you give someone who should pay this agent privately, or give it a budget.
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8081/v1/address{ "ok": true, "network": "mainnet-beta", "status": "done",
"result": { "wallet": "7xKX…", "addressCode": "h081-address:…" },
"requestId": "req_…" }GET/v1/balance
Private (shielded) balance, wallet balance, deposit caps and budgets.
| Query | What it does |
|---|---|
fresh=1 | Read the chain now. Without it, an answer under 15 seconds old comes from memory. |
shielded is the private balance. wallet is what the agent's public wallet holds. Each entry in allowances is a budget: its id (which changes after every payment from it), remaining, who holds it (holder), its caps and end date, whether you can take it back (reclaimable) and whether it backs a loan (pledged).
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8081/v1/balance
# skip the 15-second cache and read the chain now
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8081/v1/balance?fresh=1"$r = Invoke-RestMethod -Headers $h "http://127.0.0.1:8081/v1/balance?fresh=1"
"Private balance: $($r.result.shielded) USDG"
"Agent wallet: $($r.result.wallet.usdg) USDG, $($r.result.wallet.sol) SOL"
$r.result.allowances | Format-Table id, remaining, holder, reclaimabler = call("GET", "/balance", params={"fresh": "1"})["result"]
print("private:", r["shielded"], "USDG")
print("wallet: ", r["wallet"]["usdg"], "USDG,", r["wallet"]["sol"], "SOL")
for a in r["allowances"]:
print("budget", a["id"], a["remaining"], "left,", a["holder"])const r = (await call('GET', '/balance?fresh=1')).result;
console.log(`private: ${r.shielded} USDG, wallet: ${r.wallet.usdg} USDG and ${r.wallet.sol} SOL`);
for (const a of r.allowances) console.log('budget', a.id, a.remaining, 'left,', a.holder);{
"ok": true,
"network": "mainnet-beta",
"status": "done",
"result": {
"shielded": "3",
"wallet": { "usdg": "0", "sol": "0.0412" },
"caps": { "perDeposit": "1000", "total": "10000", "inVault": "…", "paused": false },
"allowances": [
{ "id": "18340563…", "remaining": "20", "holder": "sub-account 0", "agentIndex": 0,
"dailyCap": "5", "maxPerTx": "2", "expires": "2026-12-11T09:30:00.000Z",
"reclaimable": true, "pledged": false }
],
"addressCode": "h081-address:…",
"syncedAt": "2026-11-11T09:30:00.000Z"
},
"requestId": "req_…"
}GET/v1/history
What this server or the command line sent from this computer, newest first.
| Query | What it does |
|---|---|
limit | Items per page, 1 to 200. Default 50. |
after | The next value from the previous page. next is null on the last page. |
It comes from ~/.h081/ops.jsonl, so it shows only what this computer sent, in the last 7 days. Each item's id is the idempotency key it was sent with, and state is pending, done or failed (with an error code).
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8081/v1/history?limit=20"
# the next page: pass the "next" value you got back
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8081/v1/history?limit=20&after=wd-1"{ "ok": true, "network": "mainnet-beta", "status": "done",
"result": {
"items": [
{ "id": "wd-1", "time": "2026-11-11T09:31:00.000Z", "kind": "withdraw", "usdg": "2",
"signature": "…", "state": "done", "to": "<fresh wallet>" },
{ "id": "dep-1", "time": "2026-11-11T09:20:00.000Z", "kind": "deposit", "usdg": "5",
"signature": "…", "state": "done" }
],
"next": null
},
"requestId": "req_…" }GET/v1/fees/withdraw
The relayer's fee for a withdrawal, and what arrives. No proof is made.
| Query | What it does |
|---|---|
usdg or amount | The amount to withdraw, in decimal USDG or base units. |
payout is what arrives. freeTierMax is the most the relayer sends with nothing staked (100 USDG); needsStake says whether this amount is over it. createAccountFee, when present, is added if the recipient wallet has no USDG account yet.
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8081/v1/fees/withdraw?usdg=2"{ "ok": true, "network": "mainnet-beta", "status": "done",
"result": { "fee": "0.05", "feeBps": 20, "minFee": "0.05", "payout": "1.95",
"freeTierMax": "100", "needsStake": false, "relayer": "https://api.h081.xyz",
"createAccountFee": "1" },
"requestId": "req_…" }Moving money: deposit, withdraw, pay, transfer
All four return a plan first and send only with "confirm": true and an Idempotency-Key.
POST/v1/deposit
Move USDG from the agent wallet into the private (shielded) balance.
In plain words: puts money from the agent's public wallet into the bank. The deposit itself is public (the chain shows the agent wallet and the amount); what happens inside afterwards is private.
| Field | Type | What it is |
|---|---|---|
usdg or amount | string | How much: decimal USDG, or base units. Exactly one. |
confirm | boolean | true sends it. Missing or false: the plan only. |
# 1 · the plan: nothing is sent
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"usdg":"5"}' http://127.0.0.1:8081/v1/deposit
# 2 · send it: "confirm": true and an Idempotency-Key (keep KEY if you need to retry)
KEY=dep-$(date +%s)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d '{"usdg":"5","confirm":true}' http://127.0.0.1:8081/v1/deposit# 1 · the plan: nothing is sent
$body = @{ usdg = "5" } | ConvertTo-Json
Invoke-RestMethod -Method Post -Headers $h -ContentType application/json -Body $body http://127.0.0.1:8081/v1/deposit
# 2 · send it (keep $key if you need to retry)
$key = [guid]::NewGuid().ToString()
$body = @{ usdg = "5"; confirm = $true } | ConvertTo-Json
Invoke-RestMethod -Method Post -Headers ($h + @{ "Idempotency-Key" = $key }) -ContentType application/json -Body $body http://127.0.0.1:8081/v1/depositplan = call("POST", "/deposit", {"usdg": "5"})
print(plan["status"], plan["result"]["warnings"]) # "plan": nothing is sent
sent = call("POST", "/deposit", {"usdg": "5", "confirm": True}, key=str(uuid.uuid4()))
print(sent["result"]["signature"], sent["result"]["shieldedUsdg"])const plan = await call('POST', '/deposit', { usdg: '5' }); // nothing is sent
console.log(plan.status, plan.result.warnings);
const sent = await call('POST', '/deposit', { usdg: '5', confirm: true }, randomUUID());
console.log(sent.result.signature, sent.result.shieldedUsdg);{ "ok": true, "network": "mainnet-beta", "status": "plan",
"result": { "op": "deposit", "usdg": "5", "fee": "0", "solNeeded": "0.02", "circuit": "transact",
"download": "15 MB", "publicOnChain": ["agent wallet", "amount"], "sentBy": "agent key",
"warnings": ["A deposit is public: the chain shows the agent wallet and the amount. What happens inside the bank afterwards is private."],
"next": "Nothing was sent. Send it again with confirm: true (CLI: --yes) to do it." },
"requestId": "req_…" }{ "ok": true, "network": "mainnet-beta", "status": "sent",
"result": { "signature": "…", "amount": "5000000", "amountUsdg": "5",
"shielded": "5000000", "shieldedUsdg": "5", … },
"requestId": "req_…" }Deposits are capped at 1,000 USDG each and 10,000 USDG in the whole bank for now (cap_per_deposit, cap_total). The first deposit downloads its 15 MB proof file if serve has not already.
POST/v1/withdraw
Send USDG from the private balance to any wallet, through the H081 relayer.
In plain words: takes money out of the bank to a wallet. H081's relayer sends it, so the chain shows the recipient and the amount, but not your agent's wallet.
| Field | Type | What it is |
|---|---|---|
usdg or amount | string | How much leaves the bank. The fee comes out of it. |
to (required) | string | A Solana wallet address. Use a fresh one. "self" means the agent wallet; the plan then warns that this links the two. |
direct | boolean | true: the agent key sends it itself. No relay fee and no relayer limit, but the agent wallet shows as the sender. Default false: through the relayer, and refused (not sent) if the relayer is unavailable. |
confirm | boolean | true sends it. |
# 1 · the plan: fee, what arrives, what is public (nothing is sent)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"usdg":"2","to":"<fresh wallet>"}' http://127.0.0.1:8081/v1/withdraw
# 2 · send it
KEY=wd-$(date +%s)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d '{"usdg":"2","to":"<fresh wallet>","confirm":true}' http://127.0.0.1:8081/v1/withdraw$to = "<fresh wallet>"
# 1 · the plan (nothing is sent)
$body = @{ usdg = "2"; to = $to } | ConvertTo-Json
(Invoke-RestMethod -Method Post -Headers $h -ContentType application/json -Body $body http://127.0.0.1:8081/v1/withdraw).result
# 2 · send it
$key = [guid]::NewGuid().ToString()
$body = @{ usdg = "2"; to = $to; confirm = $true } | ConvertTo-Json
Invoke-RestMethod -Method Post -Headers ($h + @{ "Idempotency-Key" = $key }) -ContentType application/json -Body $body http://127.0.0.1:8081/v1/withdrawto = "<fresh wallet>" # a wallet that never touched the agent
print(call("GET", "/fees/withdraw", params={"usdg": "2"})["result"])
plan = call("POST", "/withdraw", {"usdg": "2", "to": to})
print(plan["result"]["fee"], plan["result"]["payout"], plan["result"]["warnings"]) # nothing is sent
sent = call("POST", "/withdraw", {"usdg": "2", "to": to, "confirm": True}, key=str(uuid.uuid4()))
print(sent["result"]["signature"], sent["result"]["receivedUsdg"])const to = '<fresh wallet>'; // a wallet that never touched the agent
const plan = await call('POST', '/withdraw', { usdg: '2', to }); // nothing is sent
console.log(plan.result.fee, plan.result.payout, plan.result.warnings);
const sent = await call('POST', '/withdraw', { usdg: '2', to, confirm: true }, randomUUID());
console.log(sent.result.signature, sent.result.receivedUsdg);{ "ok": true, "network": "mainnet-beta", "status": "plan",
"result": { "op": "withdraw", "usdg": "2", "to": "<fresh wallet>", "fee": "0.05", "payout": "1.95",
"solNeeded": "0", "circuit": "transact", "download": "0 MB (already downloaded)",
"publicOnChain": ["recipient", "amount received"], "sentBy": "relayer",
"warnings": ["If <fresh wallet> has no USDG account yet, the relayer adds 1 USDG to create it."],
"next": "Nothing was sent. Send it again with confirm: true (CLI: --yes) to do it." },
"requestId": "req_…" }{ "ok": true, "network": "mainnet-beta", "status": "sent",
"result": { "signature": "…", "amount": "2000000", "fee": "50000", "received": "1950000",
"receivedUsdg": "1.95", "to": "<fresh wallet>", "via": "relayer",
"shielded": "3000000", "shieldedUsdg": "3", … },
"requestId": "req_…" }- The fee is 0.20% or 0.05 USDG, whichever is more. Check it first with
GET /v1/fees/withdraw. - Up to 100 USDG per relayed withdrawal with nothing staked; above that you get
409 needs_stake. Split it, stake H081, or usedirect. - A wallet that has never held USDG has no USDG account: the relayer creates it for 1 USDG more. The plan says so.
POST/v1/pay
Pay from a budget (allowance) this agent holds, to a wallet or an h081-address: code.
In plain words: spends from a budget someone gave this agent, inside that budget's limits. Pay an h081-address: code to keep it private, or a wallet for a public payout.
| Field | Type | What it is |
|---|---|---|
usdg or amount | string | How much. |
toAddressCode | string | Private payment to this h081-address: code. Sent by the agent key: the chain shows the agent wallet made a payment, not to whom or how much. |
to | string | Public payout to this Solana wallet (through the relayer). Give exactly one of to or toAddressCode. |
allowanceId | string | Which budget to spend (its id from GET /v1/balance). Default: picked for you. |
agentIndex | integer 0 to 15 | Spend a budget held by this sub-account. |
direct | boolean | Public payout only: the agent key sends it (no relay fee, sender visible). |
allowPledged | boolean | Default false: a budget that backs a loan is never used. true accepts one; its loan slot and the lender's share then go on chain next to the payment, linking the recipient to the borrower wallet. |
confirm | boolean | true sends it. |
# 1 · the plan: pay 1 USDG privately to another H081 account (nothing is sent)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"usdg":"1","toAddressCode":"h081-address:…"}' http://127.0.0.1:8081/v1/pay
# 2 · send it
KEY=pay-$(date +%s)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d '{"usdg":"1","toAddressCode":"h081-address:…","confirm":true}' \
http://127.0.0.1:8081/v1/pay$code = "h081-address:…"
# 1 · the plan (nothing is sent)
$body = @{ usdg = "1"; toAddressCode = $code } | ConvertTo-Json
(Invoke-RestMethod -Method Post -Headers $h -ContentType application/json -Body $body http://127.0.0.1:8081/v1/pay).result
# 2 · send it
$key = [guid]::NewGuid().ToString()
$body = @{ usdg = "1"; toAddressCode = $code; confirm = $true } | ConvertTo-Json
Invoke-RestMethod -Method Post -Headers ($h + @{ "Idempotency-Key" = $key }) -ContentType application/json -Body $body http://127.0.0.1:8081/v1/pay# private: to an h081-address: code
body = {"usdg": "1", "toAddressCode": "h081-address:…"}
print(call("POST", "/pay", body)["result"]) # the plan: nothing is sent
sent = call("POST", "/pay", {**body, "confirm": True}, key=str(uuid.uuid4()))
print(sent["result"]["signature"], sent["result"].get("remaining"))
# public payout: to a fresh wallet, from one budget only (the plan).
# The budget id is the long number GET /v1/balance shows.
body = {"usdg": "1", "to": "<fresh wallet>", "allowanceId": "<budget id>"}
print(call("POST", "/pay", body)["result"])const body = { usdg: '1', toAddressCode: 'h081-address:…' };
console.log((await call('POST', '/pay', body)).result); // the plan: nothing is sent
const sent = await call('POST', '/pay', { ...body, confirm: true }, randomUUID());
console.log(sent.result.signature);{ "ok": true, "network": "mainnet-beta", "status": "plan",
"result": { "op": "pay", "usdg": "1", "to": "shielded", "fee": "0", "payout": "1",
"solNeeded": "0.02", "circuit": "spend", "download": "19 MB",
"publicOnChain": ["that the agent wallet sent a bank transaction"], "sentBy": "agent key",
"warnings": ["Pays from an allowance this agent holds, inside its caps; when none can pay, the error names the cap.",
"A payment to an address code is sent by the agent key: the chain shows the agent wallet made a payment, not to whom or how much."],
"next": "Nothing was sent. Send it again with confirm: true (CLI: --yes) to do it." },
"requestId": "req_…" }The sent result has signature, fee, received, allowanceId, remaining and more. When no budget can pay the amount right now, you get 409 allowance_blocked and the message names the limit. More: Allowances.
POST/v1/transfer
Pay from the main private balance to another H081 account's h081-address: code.
In plain words: moves money from your private balance straight into another H081 account, without it ever leaving the bank. The amount and the recipient stay hidden. The chain only sees that your agent wallet used the bank.
| Field | Type | What it is |
|---|---|---|
usdg or amount | string | How much. |
toAddressCode (required) | string | The recipient's h081-address: code. Your own code is refused (a transfer to yourself does nothing). |
confirm | boolean | true sends it. |
# 1 · the plan (nothing is sent)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"usdg":"1.5","toAddressCode":"h081-address:…"}' http://127.0.0.1:8081/v1/transfer
# 2 · send it
KEY=tr-$(date +%s)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d '{"usdg":"1.5","toAddressCode":"h081-address:…","confirm":true}' \
http://127.0.0.1:8081/v1/transfer$code = "h081-address:…"
# 1 · the plan (nothing is sent)
$body = @{ usdg = "1.5"; toAddressCode = $code } | ConvertTo-Json
(Invoke-RestMethod -Method Post -Headers $h -ContentType application/json -Body $body http://127.0.0.1:8081/v1/transfer).result
# 2 · send it
$key = [guid]::NewGuid().ToString()
$body = @{ usdg = "1.5"; toAddressCode = $code; confirm = $true } | ConvertTo-Json
Invoke-RestMethod -Method Post -Headers ($h + @{ "Idempotency-Key" = $key }) -ContentType application/json -Body $body http://127.0.0.1:8081/v1/transferbody = {"usdg": "1.5", "toAddressCode": "h081-address:…"}
print(call("POST", "/transfer", body)["result"]["warnings"]) # the plan: nothing is sent
sent = call("POST", "/transfer", {**body, "confirm": True}, key=str(uuid.uuid4()))
print(sent["result"]["signature"], sent["result"]["shieldedUsdg"])const body = { usdg: '1.5', toAddressCode: 'h081-address:…' };
console.log((await call('POST', '/transfer', body)).result.warnings); // nothing is sent
const sent = await call('POST', '/transfer', { ...body, confirm: true }, randomUUID());
console.log(sent.result.signature, sent.result.shieldedUsdg);{ "ok": true, "network": "mainnet-beta", "status": "sent",
"result": { "signature": "…", "amount": "1500000", "amountUsdg": "1.5", "to": "shielded",
"via": "direct", "shielded": "1500000", "shieldedUsdg": "1.5" },
"requestId": "req_…" }A code that fails its checksum, is the wrong kind, or was made for another network is refused with 400 invalid_code before anything else happens.
Budgets: give and take back
In plain words: a budget (allowance) lets a sub-account of your agent, or another agent, spend some of your money with limits you set: a total, a daily cap, a cap per payment, an end date. You can take back what is left at any time. The full guide: Allowances.
POST/v1/allowances
Give a sub-account or another agent a budget with limits.
| Field | Type | What it is |
|---|---|---|
budget (required) | string | The total it may ever spend, decimal USDG. |
dailyCap | string | Most it may pay per day. |
maxPerTx | string | Most per payment. |
days | number | How long it lasts, up to 3650. Default: no end date. |
unlockDays | number | Unlock the budget little by little over this many days (no more than days). Default: all at once. |
agentIndex | integer 0 to 15 | Give it to this sub-account of your agent. Default: the first free one. |
toAddressCode | string | Give it to another agent instead: its h081-address: code. |
confirm | boolean | true sends it. |
# 1 · the plan: 20 USDG budget for sub-account 0, at most 5 a day and 2 per payment, for 30 days
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"budget":"20","dailyCap":"5","maxPerTx":"2","days":30,"agentIndex":0}' \
http://127.0.0.1:8081/v1/allowances
# 2 · send it
KEY=allow-$(date +%s)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{"budget":"20","dailyCap":"5","maxPerTx":"2","days":30,"agentIndex":0,"confirm":true}' \
http://127.0.0.1:8081/v1/allowances# 1 · the plan (nothing is sent)
$rules = @{ budget = "20"; dailyCap = "5"; maxPerTx = "2"; days = 30; agentIndex = 0 }
(Invoke-RestMethod -Method Post -Headers $h -ContentType application/json -Body ($rules | ConvertTo-Json) http://127.0.0.1:8081/v1/allowances).result
# 2 · send it
$key = [guid]::NewGuid().ToString()
$rules.confirm = $true
Invoke-RestMethod -Method Post -Headers ($h + @{ "Idempotency-Key" = $key }) -ContentType application/json -Body ($rules | ConvertTo-Json) http://127.0.0.1:8081/v1/allowancesrules = {"budget": "20", "dailyCap": "5", "maxPerTx": "2", "days": 30, "agentIndex": 0}
print(call("POST", "/allowances", rules)["result"]) # the plan: nothing is sent
sent = call("POST", "/allowances", {**rules, "confirm": True}, key=str(uuid.uuid4()))
print("budget id:", sent["result"]["allowanceId"])const rules = { budget: '20', dailyCap: '5', maxPerTx: '2', days: 30, agentIndex: 0 };
console.log((await call('POST', '/allowances', rules)).result); // the plan: nothing is sent
const sent = await call('POST', '/allowances', { ...rules, confirm: true }, randomUUID());
console.log('budget id:', sent.result.allowanceId);{ "ok": true, "network": "mainnet-beta", "status": "sent",
"result": { "sent": true, "signature": "…", "allowanceId": "18340563…", "rules": { … }, … },
"requestId": "req_…" }Needs about 0.1 SOL for this call (keep 0.15 SOL in the agent wallet) and the grant proof file (about 51 MB, once). The plan's details shows the exact rules. The chain shows that your agent wallet made a grant, not the amount, the rules or the recipient.
POST/v1/allowances/{id}/reclaim
Take back what is left of a budget you gave.
Put the budget's id in the path, in place of {id}: the current id from GET /v1/balance, where it shows "reclaimable": true. The id changes after every payment from it, so read it fresh. The body takes only confirm; send {} for the plan.
# the budget's current id, from GET /v1/balance (a long number)
ID="<budget id>"
# 1 · the plan (nothing is sent)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{}' http://127.0.0.1:8081/v1/allowances/$ID/reclaim
# 2 · take it back
KEY=reclaim-$(date +%s)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" -d '{"confirm":true}' http://127.0.0.1:8081/v1/allowances/$ID/reclaim$id = "<budget id>" # its current id, from GET /v1/balance
# 1 · the plan (nothing is sent)
(Invoke-RestMethod -Method Post -Headers $h -ContentType application/json -Body '{}' "http://127.0.0.1:8081/v1/allowances/$id/reclaim").result
# 2 · take it back
$key = [guid]::NewGuid().ToString()
Invoke-RestMethod -Method Post -Headers ($h + @{ "Idempotency-Key" = $key }) -ContentType application/json -Body '{"confirm":true}' "http://127.0.0.1:8081/v1/allowances/$id/reclaim"bid = "<budget id>" # its current id, from GET /v1/balance
print(call("POST", f"/allowances/{bid}/reclaim", {})["result"]) # the plan: nothing is sent
sent = call("POST", f"/allowances/{bid}/reclaim", {"confirm": True}, key=str(uuid.uuid4()))
print(sent["result"]["reclaimedUsdg"], "USDG back")const id = '<budget id>'; // its current id, from GET /v1/balance
console.log((await call('POST', `/allowances/${id}/reclaim`, {})).result); // nothing is sent
const sent = await call('POST', `/allowances/${id}/reclaim`, { confirm: true }, randomUUID());
console.log(sent.result.reclaimedUsdg, 'USDG back');{ "ok": true, "network": "mainnet-beta", "status": "sent",
"result": { "sent": true, "signature": "…", "allowanceId": "18340563…", "reclaimed": "14000000",
"reclaimedUsdg": "14", "lenderShare": "0", "shieldedUsdg": "15.5", … },
"requestId": "req_…" }If the budget backs a loan, the lender's share of what is reclaimed goes to the lender (lenderShare); the rest returns to your private balance.
Loans
In plain words: a borrower pledges a budget that unlocks over time; a lender pays out a loan against it and is repaid from a share of every payment the budget makes. A lender can never receive more than the amount lent: anything beyond that goes back to the borrower's payout account (live on mainnet since 2026-11-10). The full guide: Loans.
GET/v1/loans/quote
Price a loan from a lender's terms code. Read-only.
| Query | What it is |
|---|---|
termsCode (required) | The lender's h081-loan-terms: code. |
principal | A loan amount to price, decimal USDG. Default: the most these terms back. |
curl -s -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode "termsCode=h081-loan-terms:…" --data-urlencode "principal=10" \
http://127.0.0.1:8081/v1/loans/quote{ "ok": true, "network": "mainnet-beta", "status": "done",
"result": { "pledgeBps": …, "minRemaining": "…", "maxPrincipal": "…", "principal": "…",
"originationFee": "…", "originationFeeBps": …, "payout": "…", "note": "…", … },
"requestId": "req_…" }POST/v1/loans/borrow
Pledge a budget to a lender's terms and get the proof code to send them.
| Field | Type | What it is |
|---|---|---|
termsCode (required) | string | The lender's h081-loan-terms: code. |
days (required) | number | How long the pledged budget lasts. |
unlockDays (required) | number | It unlocks little by little over this many days (no more than days). |
principal | string | The loan amount. The lender must fund exactly this. |
budget | string | The pledged budget. Default: sized to the principal. |
dailyCap, maxPerTx | string | Limits on the pledged budget. |
agentIndex | integer 0 to 15 | Which sub-account. Default: the first free one. |
confirm | boolean | true sends it. |
# 1 · the plan, with the quote in "details" (nothing is sent)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"termsCode":"h081-loan-terms:…","days":30,"unlockDays":30,"principal":"10"}' \
http://127.0.0.1:8081/v1/loans/borrow
# 2 · pledge the budget and get the proof code for the lender
KEY=borrow-$(date +%s)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{"termsCode":"h081-loan-terms:…","days":30,"unlockDays":30,"principal":"10","confirm":true}' \
http://127.0.0.1:8081/v1/loans/borrow{ "ok": true, "network": "mainnet-beta", "status": "sent",
"result": { "proofCode": "h081-loan-proof:…", "loanRef": "…", "allowanceId": "…", "principal": "…",
"maxPrincipal": "…", "payoutAccount": "…", "next": "…", … },
"requestId": "req_…" }Nothing is borrowed until the lender funds the proof code. Needs about 0.1 SOL and the grant and stream proof files.
POST/v1/loans/lend
Lender actions: open a slot, fund a proof code, collect, claim, settle.
| Field | Used by | What it is |
|---|---|---|
action (required) | all | open, fund, collect, claim or settle. |
pledgeBps | open, fund | open: the share of every borrower payment routed to the slot, in basis points (100 = 1%; from 100 to 9000). fund: the least share the proof must pledge. |
proofCode | fund | The borrower's h081-loan-proof: code. |
loanRef | fund, collect, claim, settle | The loan slot number. Required for collect, claim and settle; fund defaults to the slot in the proof code. |
principal / principalUsdg | fund | The amount you expect to lend (base units / decimal). Refused unless it is exactly what the proof backs. |
amount / usdg | claim | Claim at most this much (base units / decimal). Default: everything claimable now. |
confirm | all | true sends it. |
# Each line below is a plan: nothing is sent. To send one, add "confirm":true to the body
# and an Idempotency-Key header, as in the deposit example.
# open a slot: 20% of every borrower payment goes to it
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"action":"open","pledgeBps":2000}' http://127.0.0.1:8081/v1/loans/lend
# fund a borrower's proof code
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"action":"fund","proofCode":"h081-loan-proof:…"}' http://127.0.0.1:8081/v1/loans/lend
# later: claim what has been repaid into slot 12
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"action":"claim","loanRef":"12"}' http://127.0.0.1:8081/v1/loans/lend
# then send the one you want, e.g. the fund
KEY=fund-$(date +%s)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{"action":"fund","proofCode":"h081-loan-proof:…","confirm":true}' http://127.0.0.1:8081/v1/loans/lendcollect takes shares already routed to the slot; settle closes the books. "action": "status" is refused here: use GET /v1/loans/lent. close answers 501 not_available in this version. Every lender action is sent by the agent key, so the agent wallet is public as the lender.
GET/v1/loans/lent
This agent's loan slots as a lender.
| Query | What it is |
|---|---|
loanRef | One slot. Default: all of this agent's slots. |
Each slot shows its state (open, funded, repaid, settled or closed), what is outstanding, claimable and collectable, the borrower's excess (which goes back to the borrower, never to you), and any shortfall.
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8081/v1/loans/lent
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8081/v1/loans/lent?loanRef=12"{ "ok": true, "network": "mainnet-beta", "status": "done",
"result": { "slots": [ { "loanRef": "12", "state": "funded", "principalUsdg": "…", "repaidUsdg": "…",
"outstandingUsdg": "…", "claimableUsdg": "…", "collectableUsdg": "…",
"borrowerExcessUsdg": "…", "shortfall": "…", … } ] },
"requestId": "req_…" }Staking H081
In plain words: relayed withdrawals above 100 USDG need some H081 staked by the agent wallet. Staking names the agent wallet to the relayer. Unstaking has a 30-day cooldown.
GET/v1/stake
The agent wallet's H081 stake.
Amounts are in H081 base units (6 decimals). pending is what is waiting out the cooldown, and readyAt is when it can be unstaked (Unix time, in seconds).
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8081/v1/stake{ "ok": true, "network": "mainnet-beta", "status": "done",
"result": { "signer": "7xKX…", "walletH081": "…", "staked": "…", "pending": "…", "readyAt": "…",
"cooldownDays": 30, "caps": { … }, … },
"requestId": "req_…" }POST/v1/stake
Stake, request unstake (starts the 30-day cooldown), or unstake H081.
| Field | Type | What it is |
|---|---|---|
action (required) | string | stake, request_unstake or unstake. (To look, use the GET route.) |
h081 | string | An H081 amount, decimal, e.g. "1000". Required for stake and request_unstake; unstake takes no amount. |
confirm | boolean | true sends it. |
# the plan for staking 1000 H081 (nothing is sent)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"action":"stake","h081":"1000"}' http://127.0.0.1:8081/v1/stake
# send it: "confirm": true and an Idempotency-Key
KEY=stake-$(date +%s)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d '{"action":"stake","h081":"1000","confirm":true}' http://127.0.0.1:8081/v1/stake
# later, the plan for starting the 30-day cooldown before unstaking (nothing is sent)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"action":"request_unstake","h081":"1000"}' http://127.0.0.1:8081/v1/stake{ "ok": true, "network": "mainnet-beta", "status": "plan",
"result": { "op": "stake", "fee": "0", "solNeeded": "0.01", "circuit": null, "download": "0 MB",
"publicOnChain": ["agent wallet", "H081 amount"], "sentBy": "agent key", "warnings": [],
"details": { "action": "stake", "h081": "1000" },
"next": "Nothing was sent. Send it again with confirm: true (CLI: --yes) to do it." },
"requestId": "req_…" }Checking codes
POST/v1/codes/check
Check any h081-* code: its kind, its network, and whether it is valid.
In plain words: before you send money to a code someone gave you, this checks it was copied correctly and is for the right network. It works offline and sends nothing, so it needs no Idempotency-Key.
| Field | Type | What it is |
|---|---|---|
code (required) | string | An h081-address:, h081-loan-terms:, h081-loan-proof: or h081-key: code. |
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"code":"h081-address:…"}' http://127.0.0.1:8081/v1/codes/check{ "ok": true, "network": "mainnet-beta", "status": "done",
"result": { "kind": "address", "network": "mainnet-beta", "valid": true },
"requestId": "req_…" }{ "ok": true, "network": "mainnet-beta", "status": "done",
"result": { "kind": null, "network": null, "valid": false,
"reason": "not an H081 code (it should start with \"h081-\")" },
"requestId": "req_…" }kind is address, key, loan-terms or loan-proof. An h081-key: code is the agent's secret key: check it only here, on your own computer, and never paste it anywhere else.
Live events
GET/v1/events
A stream of live updates (server-sent events): balance changes, budget changes, finished and failed sends.
In plain words: instead of asking "has anything changed?" over and over, your program keeps one connection open and the server tells it when something happens.
| Event | Data | When |
|---|---|---|
balance.changed | shielded, wallet, deltaShielded | The private or wallet balance changed. |
allowance.changed | id, remaining | A budget changed, or is gone ("remaining": "0"). |
op.completed | idempotencyKey, kind, signature | A confirmed write from this server went out. |
op.failed | idempotencyKey, kind, code | A confirmed write from this server failed. |
- Every event's data also has
time. Event ids count up. - While at least one listener is connected, the server re-reads the chain every
--pollseconds (default 20) and sends what changed. With no listener, it does not poll. - After a dropped connection, send the last id you saw as the
Last-Event-IDheader to get what you missed (the last 1,000 events are kept in memory). - A
: pingcomment arrives every 15 seconds to keep the connection open. Ignore it.
curl -N -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8081/v1/events# pip install requests
import json, pathlib, requests
TOKEN = (pathlib.Path.home() / ".h081" / "api-token").read_text().strip()
URL = "http://127.0.0.1:8081/v1/events"
last_id = None # sent back as Last-Event-ID to resume after a drop
while True:
headers = {"Authorization": "Bearer " + TOKEN}
if last_id:
headers["Last-Event-ID"] = last_id
try:
with requests.get(URL, headers=headers, stream=True, timeout=(5, 60)) as r:
r.raise_for_status()
event, data = None, None
for line in r.iter_lines(decode_unicode=True):
if line.startswith("id: "):
last_id = line[4:]
elif line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
data = json.loads(line[6:])
elif line == "" and event: # a blank line ends one event
print(event, data)
if event == "balance.changed":
print("private balance is now", data["shielded"], "USDG")
event, data = None, None
except requests.RequestException as e:
print("reconnecting:", e)// Node.js 20+. A browser EventSource cannot send the Authorization header, so this reads the stream with fetch.
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
const token = readFileSync(join(homedir(), '.h081', 'api-token'), 'utf8').trim();
const res = await fetch('http://127.0.0.1:8081/v1/events', { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok || !res.body) throw new Error(`events: HTTP ${res.status}`);
const decoder = new TextDecoder();
let buf = '';
for await (const chunk of res.body) {
buf += decoder.decode(chunk, { stream: true });
let end;
while ((end = buf.indexOf('\n\n')) >= 0) { // a blank line ends one event
const block = buf.slice(0, end);
buf = buf.slice(end + 2);
const ev: Record<string, string> = {};
for (const line of block.split('\n')) {
const i = line.indexOf(': ');
if (i > 0) ev[line.slice(0, i)] = line.slice(i + 2); // skips ": ping" comments
}
if (ev.event) console.log(ev.id, ev.event, JSON.parse(ev.data));
}
}retry: 3000
id: 12
event: balance.changed
data: {"shielded":"3","wallet":{"usdg":"0","sol":"0.0412"},"deltaShielded":"-2","time":"2026-11-11T09:31:02.114Z"}
id: 13
event: op.completed
data: {"idempotencyKey":"wd-1","kind":"withdraw","signature":"…","time":"2026-11-11T09:31:02.120Z"}
: pingErrors in short
Switch on error.code. If retryable is true, wait (use Retry-After when it is there) and send the same request with the same key. Every code with its fix: Errors and fixes.
| HTTP | Codes |
|---|---|
| 400 | bad_request, idempotency_key_required, invalid_code |
| 401 | unauthorized |
| 403 | forbidden_origin |
| 404 | not_found |
| 409 | in_progress (retryable), outcome_unknown, insufficient_shielded, insufficient_wallet_usdg, needs_sol, cap_per_deposit, cap_total, deposits_paused, over_operator_ceiling, needs_stake, allowance_blocked, stale_root (retryable), in_flight (retryable), already_spent |
| 415 | unsupported_media_type |
| 422 | idempotency_key_reused, simulation_failed, program_error |
| 429 | rate_limited (retryable) |
| 500 | no_key, internal |
| 501 | not_available |
| 502 | rpc_unreachable (retryable) |
| 503 | relayer_unavailable (retryable), relayer_busy (retryable), busy (retryable), circuits_missing, circuit_hash_mismatch |
The OpenAPI file
Every route, field and error code in one machine-readable file (OpenAPI 3.1). Three ways to get it:
- On this site: /h081-agent-openapi.json.
- From a running server:
GET /v1/openapi.json(no token). - From the command line, no server needed:
npx -y @h081lll/agent openapi > h081-openapi.jsonNext
- JavaScriptTypeScript SDKThe same bank in your Node.js code, without running a server.
- AllowancesBudgets with limits, step by step.
- Errors and fixesEvery error code, what it means and what to do.
- Privacy and safetyWhat the chain can see, and how to stay safe.
New here? The 5-minute quickstart does a first private payment end to end. The package is @h081lll/agent on npm. Questions: @H081CAP on X.