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.
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
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.