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:

typewhocan
agentthe agent runtime (AA_PLATFORM_KEY)enroll, heartbeat, check, receipt, read policy
orga humanlist 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:

reasonmeaning
killedstatus is killed; every check denies
pausedstatus is paused; deny until resumed
chain_mismatchbody chain is not the agent's chain
merchant_blocked / pay_to_blockedblocklist
merchant_not_allowed / pay_to_not_allowedallowlist is set and did not match
exceeds_per_paymentabove max / payment
exceeds_24hrolling 24 hours, including pending holds
exceeds_monthcalendar 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"
}

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.