SpendOS API
SpendOS is a hosted control plane for non-custodial agent spend wallets. These calls are what an agent makes. Keys stay on the agent host. Send only a public address.
Base URL: https://spendos.org (local docker: http://localhost:3000).
Authorization: Bearer <key>
Content-Type: application/json
Two key types:
| type | who | can |
|---|---|---|
agent | the agent runtime (AA_PLATFORM_KEY) | enroll, heartbeat, check, receipt, read policy |
org | a human | list agents, list receipts, list keys, change policy |
Revoking an agent key does not revoke the org key. Raw secrets are shown once. Pairing codes are separate short-lived rows (SPEND-XXXX, 60 minutes, single use).
Money is a USD decimal string with at most 6 places ("0.10", "1.50"). Stored as USDC micros (6 decimals). There is no private-key field. A body that contains one is rejected.
Errors:
{ "error": "unauthorized", "message": "Bearer API key required" }
POST /v1/agents/enroll
Auth: agent key, or a pairing code with no key. The org is taken from the key, or from the code. If both are sent, they must be the same org.
{
"address": "0xabc…",
"chain": "base",
"label": "research-bot",
"sdkVersion": "x402-aa-wallet@0.3.1",
"pairingCode": "SPEND-7K2Q"
}
chain is base or solana. Upserts (org, chain, address). A new agent starts paused with zero caps, so it cannot spend until a human sets policy. Re-enrolling does not reset policy.
201 on create, 200 on update:
{
"agentId": "uuid",
"created": true,
"policy": {
"status": "paused",
"policyVersion": 1,
"maxPerPaymentUsd": "0.00",
"maxPer24hUsd": "0.00",
"maxPerMonthUsd": "0.00",
"merchants": [],
"payTo": [],
"blocklist": []
}
}
POST /v1/agents/heartbeat
Auth: agent key.
{ "agentId": "uuid" }
Or { "address": "0x…", "chain": "base" }.
{
"ok": true,
"agentId": "uuid",
"status": "live",
"policyVersion": 3,
"lastHeartbeatAt": "2026-09-22T12:00:00.000Z"
}
Heartbeat does not spend. A killed agent still heartbeats, and the response says killed.
GET /v1/agents/:id/policy
Auth: agent key or org key. Only agents in that key's org.
{
"agentId": "uuid",
"label": "research-bot",
"chain": "base",
"address": "0xabc…",
"sdkVersion": "x402-aa-wallet@0.3.1",
"status": "live",
"policyVersion": 3,
"maxPerPaymentUsd": "1.00",
"maxPer24hUsd": "10.00",
"maxPerMonthUsd": "50.00",
"merchants": [],
"payTo": [],
"blocklist": []
}
Empty merchants allows any host. Empty payTo allows any address. blocklist is hosts or addresses and is checked first.
POST /v1/spend/check
Auth: agent key.
This is an authorization with a reservation. It is not “read the sum, then allow”. The policy row is locked for the duration of the transaction. Captured spend, plus pending reservations, plus this amount, cannot exceed the 24h cap or the month cap. A per-payment cap is applied too.
A check does not increment captured spend. A check is not a receipt.
{
"agentId": "uuid",
"amountUsd": "0.25",
"merchant": "api.example.com",
"payTo": "0xpayee…",
"chain": "base",
"requestUrl": "https://api.example.com/data"
}
merchant is a host (a URL is reduced to its host). payTo and chain are optional. If chain is set, it must match the agent.
200 either way:
{
"allow": true,
"reason": "allowed",
"checkId": "uuid",
"expiresAt": "2026-09-22T12:02:00.000Z",
"remainingUsd24h": "9.75",
"remainingUsdMonth": "49.75",
"policyVersion": 3
}
On allow, a pending reservation is written for checkId. It expires in about 2 minutes. remainingUsd* is what is left after that hold.
On deny, checkId and expiresAt are null, and nothing is reserved. remainingUsd* is unchanged. reason is one of:
| reason | meaning |
|---|---|
killed | status is killed; every check denies |
paused | status is paused; deny until resumed |
chain_mismatch | body chain is not the agent's chain |
merchant_blocked / pay_to_blocked | blocklist |
merchant_not_allowed / pay_to_not_allowed | allowlist is set and did not match |
exceeds_per_payment | above max / payment |
exceeds_24h | rolling 24 hours, including pending holds |
exceeds_month | calendar month, UTC, including pending holds |
Two concurrent checks for $8 against $10 left: one allow, one exceeds_24h.
The 24h window is rolling. The month window starts at 00:00 UTC on the first of the month.
POST /v1/spend/receipt
Auth: agent key.
{
"checkId": "uuid",
"agentId": "uuid",
"amountUsd": "0.25",
"merchant": "api.example.com",
"chain": "base",
"txHash": "0x…",
"requestUrl": "https://api.example.com/data",
"status": "paid"
}
status is paid, failed, or skipped. checkId, txHash, and requestUrl are optional.
{
"receiptId": "uuid",
"status": "paid",
"applied": true,
"idempotent": false,
"reason": null,
"checkId": "uuid"
}
paidand a live check: capture. The reservation closes and the receipt amount counts as spend, when the amount is less than or equal to the reserved amount. A larger amount is400 amount_exceeds_reservationand the hold stays until you send the authorized amount or it expires.failedorskippedand a live check: release the reservation. It does not count as spend.paidwith no check, or a check that already expired: the receipt is always stored.appliedis true only when the amount still fits under the per-payment, 24h, and month caps. Otherwiseappliedis false andreasonisexceeds_caporreservation_expired.- The same
txHashreturns the original receipt (idempotent: true) and does not apply twice. - The same
checkId+statusreturns the original receipt. A different status after the reservation was captured is409 reservation_closed.
Do not treat the check response as a receipt. If the payment fails, send failed so the hold is released before the two minutes are up.
GET /v1/receipts
Auth: org key.
GET /v1/receipts?agentId=<uuid>&from=<iso>&to=<iso>
from is inclusive, to is exclusive. Both are optional.
{
"receipts": [
{
"id": "uuid",
"agentId": "uuid",
"agentLabel": "research-bot",
"checkId": "uuid",
"amountUsd": "0.25",
"merchant": "api.example.com",
"chain": "base",
"txHash": "0x…",
"requestUrl": "https://api.example.com/data",
"status": "paid",
"applied": true,
"occurredAt": "2026-09-22T12:00:05.000Z"
}
]
}
Org admin
These are not required on the agent. They use the org key.
GET /v1/agents
{
"agents": [
{
"id": "uuid",
"label": "research-bot",
"chain": "base",
"address": "0x…",
"status": "live",
"policyVersion": 3,
"lastHeartbeatAt": "2026-09-22T12:00:00.000Z",
"createdAt": "2026-09-22T11:00:00.000Z",
"remainingUsd24h": "9.75",
"remainingUsdMonth": "49.75"
}
]
}
GET /v1/keys
Prefixes only. Hashes and raw secrets are not returned.
{
"keys": [
{
"id": "uuid",
"prefix": "sp_org_seed_dev_",
"type": "org",
"label": "seed org",
"createdAt": "2026-09-22T11:00:00.000Z",
"revokedAt": null
}
]
}
PATCH /v1/agents/:id/policy
Auth: org key. Omitted fields stay as they are. An empty list clears that allowlist (empty means any).
{
"label": "research-bot",
"status": "live",
"maxPerPaymentUsd": "1.00",
"maxPer24hUsd": "10.00",
"maxPerMonthUsd": "50.00",
"merchants": ["api.example.com"],
"payTo": [],
"blocklist": ["bad.example"]
}
status is live, paused, or killed. Saving increments policyVersion.