Install

SpendOS is a hosted control plane for non-custodial agent spend wallets.

The same snippet as an empty org

In the app, the key and pairing code are filled in for your org. Production URL is https://spendos.org. When AA_PLATFORM_KEY is set, SpendOS policy is the source of truth. Local maxAmountUsd is an extra seatbelt.

no SDK yet
npm i x402-aa-wallet

export AA_PLATFORM_KEY=sp_agt_…
export AA_PLATFORM_URL=https://spendos.org

import { createSpendWallet } from "x402-aa-wallet";

// Run once, on the agent host. Persist wallet.privateKey in your own secret store.
// The private key never leaves your agent host. Do not send it to SpendOS.
const wallet = createSpendWallet();

const enrolled = await fetch(process.env.AA_PLATFORM_URL + "/v1/agents/enroll", {
  method: "POST",
  headers: {
    authorization: "Bearer " + process.env.AA_PLATFORM_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    address: wallet.address,
    chain: "base",
    // Optional. Only the first enroll uses this. Later boots do not rename the agent.
    label: process.env.AA_AGENT_LABEL || undefined,
    sdkVersion: "x402-aa-wallet@0.3.1",
    pairingCode: "SPEND-7K2Q", // single use, 60 minutes; omit on later boots
  }),
});

console.log(await enrolled.json());

The private key never leaves your agent host.

SDK already installed
already installed
import { spendWalletFromPrivateKey, x402Fetch } from "x402-aa-wallet";

const wallet = spendWalletFromPrivateKey(process.env.SPEND_WALLET_PRIVATE_KEY);
const base = process.env.AA_PLATFORM_URL;
const headers = {
  authorization: "Bearer " + process.env.AA_PLATFORM_KEY,
  "content-type": "application/json",
};

const enrolled = await fetch(base + "/v1/agents/enroll", {
  method: "POST",
  headers,
  body: JSON.stringify({
    address: wallet.address,
    chain: "base",
    sdkVersion: "x402-aa-wallet@0.3.1",
  }),
}).then((res) => res.json());

await fetch(base + "/v1/agents/heartbeat", {
  method: "POST",
  headers,
  body: JSON.stringify({ agentId: enrolled.agentId }),
});

const amountUsd = "0.10";
const merchant = "api.example.com";
const requestUrl = "https://api.example.com/data";

const check = await fetch(base + "/v1/spend/check", {
  method: "POST",
  headers,
  body: JSON.stringify({
    agentId: enrolled.agentId,
    amountUsd,
    merchant,
    chain: "base",
    requestUrl,
  }),
}).then((res) => res.json());

if (!check.allow) throw new Error(check.reason);

// SpendOS policy is the source of truth. maxAmountUsd is a local seatbelt.
const pay = x402Fetch(wallet, { maxAmountUsd: Number(amountUsd) });
let status = "failed";
try {
  const res = await pay(requestUrl);
  status = res.ok ? "paid" : "failed";
} catch {
  status = "failed";
}

await fetch(base + "/v1/spend/receipt", {
  method: "POST",
  headers,
  body: JSON.stringify({
    checkId: check.checkId,
    agentId: enrolled.agentId,
    amountUsd,
    merchant,
    chain: "base",
    requestUrl,
    status,
  }),
});

Check versus receipt

POST /v1/spend/check is an authorization. It places a reservation of about 2 minutes so two payments cannot both pass a 24h or month cap. It does not count as spend.

POST /v1/spend/receipt reports what happened. paid with a live check captures the reservation into spend. failed or skipped releases it. A paid receipt without a check is recorded, and it counts only when it still fits under the caps.

Kill denies every check. Pause denies every check until you resume. Money is integer USDC micros, 6 decimals. Chains in the data model are base (default) and solana.

API reference