StableOps

Quickstart

Configure the payment rails as the operator so an autonomous agent can initiate stablecoin payments under policy, budget, and approval controls.

Agent Payments separates two concerns:

  • The operator (you) provisions the agent's "payment rails" up front: register a payment wallet, pin an immutable spending policy, set budgets, and finally issue a restricted Agent Key. You are also the approver.
  • The agent runtime (your autonomous program) only receives that Agent Key plus the local signer URL and authentication token. It can call four least-privilege tools and cannot change the wallet, policy, budget, or approvals, and never sees a private key.

Every payment passes through policy checks (origin, recipient, amount), organization- and agent-level daily budgets, human approval when required, and finally a signature over a precisely bound short-lived execution grant produced by your own signer. StableOps does not proxy the business request, broadcast the transaction, or hold a private key.

Everything on this page can also be done by clicking through the console at /agent-payments/dashboard (create an agent, register a wallet, write a policy, set budgets, issue an Agent Key, approve). The steps below use the management SDK to show the same flow so you can put it in infrastructure code.

Prerequisites

  • Node.js 20 or later. These SDKs run in server-side Node.js, not in browsers or edge runtimes.
  • A StableOps organization and a management API key (STABLEOPS_API_KEY, used to configure the rails; never ship it to the agent runtime).
  • Testing uses the sandbox environment: the network is Base Sepolia (eip155:84532), the asset is official test USDC (0x036CbD53842c5426634e7929541eC2318f3dCF7e, 6 decimals), and the protocol is x402 v2 exact.
  • A Base Sepolia test wallet private key holding enough test USDC. The x402 facilitator submits the current EIP-3009 payment, so the payer wallet does not need test ETH. The same private key both registers the wallet and runs the signer.
  • The StableOps sandbox execution-grant public key (Ed25519) and its key id, so the signer can verify the grant's origin.

Amounts are always atomic-unit strings. USDC has 6 decimals, so 1000000 = 1 USDC and 100000 = 0.1 USDC. Sandbox platform ceilings: per payment ≤ 1 USDC (1000000), per agent ≤ 10 USDC/day, per organization ≤ 100 USDC/day.

The execution-grant public key is not the payment-wallet or recipient public key. Obtain the current key ID, public-key file, and fingerprint from trusted StableOps onboarding material. Do not continue without a matching key, and never substitute an arbitrary public key.

1. Install the packages

The three packages split by role and are published independently:

pnpm add @stableops/agent-payments-api-sdk @stableops/agent-sdk @stableops/agent-signer viem
PackageRuns wherePurpose
@stableops/agent-payments-api-sdkOperator backendConfigure agents, wallets, policies, and budgets; query approvals and payments
@stableops/agent-signerYour signerValidate execution grants and sign with a local key or AWS KMS
@stableops/agent-sdkAgent runtimeInitiate payments under policy control; expose least-privilege tools

1.1 Prepare environment variables

Create a server-only .env.local and do not commit it:

STABLEOPS_API_URL=https://api.stableops.dev
STABLEOPS_API_KEY=sk_sandbox_replace_with_agent_payments_management_key

X402_RESOURCE_URL=https://x402-base-sepolia-resource.vercel.app/api/x402/resource
BASE_SEPOLIA_TEST_PRIVATE_KEY=0x_replace_with_64_hex_characters
EXPECTED_WALLET_ADDRESS=0x_replace_with_payment_wallet_address

STABLEOPS_GRANT_KEY_ID=replace_with_stableops_key_id
STABLEOPS_GRANT_PUBLIC_KEY_FILE=./grant-public.pem

STABLEOPS_SIDECAR_URL=http://127.0.0.1:8789
STABLEOPS_SIDECAR_TOKEN=replace_with_a_high_entropy_random_token

# Fill this after section 2.5.
STABLEOPS_AGENT_KEY=

Generate the sidecar token with openssl rand -hex 32. Save the exact Ed25519 public-key PEM supplied by StableOps at STABLEOPS_GRANT_PUBLIC_KEY_FILE.

The examples below use this helper so a missing security setting fails closed:

function required(name: string): string {
  const value = process.env[name]?.trim()
  if (!value) throw new Error(`Missing environment variable ${name}`)
  return value
}

2. Configure the payment rails (operator)

This code runs in your operator backend, not in the Agent runtime. Read and validate the real 402 quote, wallet, and balance before creating platform resources.

2.1 Inspect the quote and validate the payment wallet

import {
  createPublicClient,
  erc20Abi,
  formatUnits,
  getAddress,
  http,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { baseSepolia } from 'viem/chains'
import {
  parseX402Requirement,
  SafeHttpsRequester,
} from '@stableops/agent-sdk'

const BASE_SEPOLIA_USDC = getAddress(
  '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
)
const resourceUrl = new URL(required('X402_RESOURCE_URL'))
const rawPrivateKey = required('BASE_SEPOLIA_TEST_PRIVATE_KEY')
const privateKey = (rawPrivateKey.startsWith('0x')
  ? rawPrivateKey
  : `0x${rawPrivateKey}`) as `0x${string}`

if (!/^0x[0-9a-fA-F]{64}$/.test(privateKey)) {
  throw new Error('BASE_SEPOLIA_TEST_PRIVATE_KEY must be a 32-byte hex private key')
}

const account = privateKeyToAccount(privateKey)
if (account.address !== getAddress(required('EXPECTED_WALLET_ADDRESS'))) {
  throw new Error('The private key does not match EXPECTED_WALLET_ADDRESS')
}

const requester = new SafeHttpsRequester()
const challengeResponse = await requester.get(resourceUrl.toString())
if (challengeResponse.status !== 402) {
  throw new Error(`The resource returned ${challengeResponse.status}, not 402`)
}

const requirement = parseX402Requirement(challengeResponse).selected
if (
  requirement.scheme !== 'exact' ||
  requirement.network !== 'eip155:84532' ||
  getAddress(requirement.asset) !== BASE_SEPOLIA_USDC
) {
  throw new Error('The quote is outside the supported Base Sepolia USDC exact scope')
}

const publicClient = createPublicClient({
  chain: baseSepolia,
  transport: http(),
})
const usdcBalance = await publicClient.readContract({
  address: BASE_SEPOLIA_USDC,
  abi: erc20Abi,
  functionName: 'balanceOf',
  args: [account.address],
})
if (usdcBalance < BigInt(requirement.amount)) {
  throw new Error(
    `Insufficient test USDC: balance ${formatUnits(usdcBalance, 6)}, quote ${formatUnits(BigInt(requirement.amount), 6)}`,
  )
}

This step only reads the quote and onchain balance. It does not create an Intent, sign, or pay.

2.2 Create the management client and Agent

The API key determines the environment, so no separate environment option is needed:

import { StableOpsAgentPayments } from '@stableops/agent-payments-api-sdk'

const management = new StableOpsAgentPayments({
  apiKey: required('STABLEOPS_API_KEY'),
  baseUrl: required('STABLEOPS_API_URL'),
})

const agent = await management.agents.create({
  name: 'research-agent',
  description: 'Buys paid research data on demand',
})

2.3 Register and bind a payment wallet

Wallet registration is a one-time proof of address ownership: the management API issues a challenge string, you sign it with the wallet private key, and after StableOps verifies it, it registers the address, records the signer type, and sets it as the agent's default wallet on that network. The private key never leaves your machine.

// 1) Request a challenge
const challenge = await management.wallets.createPairingChallenge({
  network: 'eip155:84532',
  address: account.address,
})

// 2) Sign the challenge text with the wallet private key (EIP-191 personal_sign)
const signature = await account.signMessage({ message: challenge.message })

// 3) Register the wallet (sandbox uses the local test signer)
const wallet = await management.wallets.register({
  challengeId: challenge.challengeId,
  signature,
  signerType: 'LOCAL_TEST',
  signerKeyId: `local:${account.address.toLowerCase()}`,
})

// 4) Bind it as the agent's default payment wallet
await management.wallets.bind(agent.id, {
  agentWalletId: wallet.id,
  network: 'eip155:84532',
})

The platform only stores the verified public address, the signer type, and the "this agent may use this wallet" authorization. Without a default wallet, no payment can be initiated.

2.4 Create and activate a spending policy

This step decides what the agent can pay automatically and what needs a human. A new agent's default policy leaves the allowlists empty and sets the automatic threshold to 0, so every payment requires manual approval. To let the agent pay automatically within limits, write an explicit policy and activate it. Policy versions are immutable, so changing the rules means creating a new version and activating it.

const version = await management.agents.createPolicyVersion(agent.id, {
  network: 'eip155:84532',
  asset: {
    symbol: 'USDC',
    contractAddress: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
  },
  // Pin the first test to the values in the real 402 quote.
  allowedOrigins: [resourceUrl.origin],
  allowedPayTo: [getAddress(requirement.payTo)],
  automaticPaymentThresholdAtomic: requirement.amount,
  perPaymentLimitAtomic: requirement.amount,
  agentDailyLimitAtomic: requirement.amount,
  requireApprovalForUnknownOrigin: true,
  requireApprovalForUnknownPayTo: true,
})

await management.agents.activatePolicyVersion(agent.id, version.id)

After activation, every payment request is judged by these three rules:

CaseResult
amount > perPaymentLimitAtomicRejected outright, cannot pay
origin in allowedOrigins, recipient in allowedPayTo, and amount ≤ automaticPaymentThresholdAtomicAuto-approved, the agent does not wait
otherwise (unknown origin, unknown recipient, or above the automatic threshold but within the per-payment limit)Goes to manual approval (valid for 30 minutes by default)

Constraints: automaticPaymentThresholdAtomic must not exceed perPaymentLimitAtomic; perPaymentLimitAtomic must not exceed the sandbox platform per-payment ceiling 1000000; agentDailyLimitAtomic must not exceed 10000000. Origins must be HTTPS origins without a path.

2.5 Set budgets (optional) and issue an Agent Key

In sandbox, both the organization and the agent already have default daily budgets (100 / 10 USDC), so you can start without configuring them. Use these calls to set the limits you want, up to the platform ceilings:

await management.budgets.updateOrganization('50000000') // organization 50 USDC/day
await management.budgets.updateAgent(agent.id, '5000000') // this agent 5 USDC/day

Finally, issue the Agent Key. The plaintext is returned only this once, so store it in the agent runtime's secret manager; it is the only credential the runtime holds:

const credential = await management.agents.createKey(agent.id, {
  name: 'research-agent-runtime',
})

if (!credential.secret) throw new Error('The API did not return the one-time Agent Key')
console.log(`STABLEOPS_AGENT_KEY=${credential.secret}`) // Print only during secure initial setup.

Immediately save the plaintext in the Agent runtime's secret manager or local .env.local. If it is lost, revoke it and issue a replacement; the API cannot return it again.

3. Run the signer sidecar

The signer is a small process you host yourself that listens only on the loopback address. It validates every field of the execution grant StableOps issues (agent, wallet, network, asset, recipient, amount, nonce, validity) and signs with the private key only on an exact match; any arbitrary signing request is rejected.

Start the local signer with the same private key from section 2.3:

import { readFileSync } from 'node:fs'
import {
  FileGrantAuthorizationStore,
  LocalTestSigner,
  startSignerSidecar,
} from '@stableops/agent-signer'

const signer = new LocalTestSigner({
  privateKey: required('BASE_SEPOLIA_TEST_PRIVATE_KEY') as `0x${string}`,
  environment: 'SANDBOX',
  network: 'eip155:84532',
  grantVerification: {
    // The key id and Ed25519 public key are published by StableOps
    publicKeys: {
      [required('STABLEOPS_GRANT_KEY_ID')]: readFileSync(
        required('STABLEOPS_GRANT_PUBLIC_KEY_FILE'),
        'utf8',
      ),
    },
  },
  store: new FileGrantAuthorizationStore('./data/grant-authorizations.json'),
})

await startSignerSidecar({
  signer,
  host: '127.0.0.1',
  port: 8789,
  authToken: required('STABLEOPS_SIDECAR_TOKEN'), // authenticates calls from the agent runtime
})

Do not expose the signer to the public internet. In production, switch to AwsKmsSigner.create() and keep the private key in AWS KMS; see the signer.

4. Wire up the agent runtime

Inside the agent process, connect to the control plane with the Agent Key and to the local signer by address to assemble a StableOpsAgent. There is no private key, management API key, or wallet authority here.

import {
  AgentPaymentsControlClient,
  HttpAgentSignerSidecar,
  SafeHttpsRequester,
  StableOpsAgent,
} from '@stableops/agent-sdk'

const agent = new StableOpsAgent({
  control: new AgentPaymentsControlClient({
    agentKey: required('STABLEOPS_AGENT_KEY'), // ak_sandbox_...
    baseUrl: required('STABLEOPS_API_URL'),
  }),
  sidecar: new HttpAgentSignerSidecar({
    url: required('STABLEOPS_SIDECAR_URL'), // http://127.0.0.1:8789
    authToken: required('STABLEOPS_SIDECAR_TOKEN'),
  }),
  requester: new SafeHttpsRequester(), // forces HTTPS, blocks private-range addresses, limits redirects
})

Expose the four tool definitions to your model and forward each tool call to the instance above:

import { agentPaymentTools } from '@stableops/agent-sdk'

// agentPaymentTools are framework-neutral tool definitions you can feed straight to your LLM
async function runTool(name: string, args: Record<string, unknown>) {
  switch (name) {
    case 'stableops_get_budget':
      return agent.getBudget()
    case 'stableops_x402_fetch':
      return agent.x402Fetch(args.url as string, {
        idempotencyKey: args.idempotencyKey as string | undefined,
        resumeIntentId: args.resumeIntentId as string | undefined,
      })
    case 'stableops_get_payment':
      return agent.getPayment(args.intentId as string)
    case 'stableops_list_recent_payments':
      return agent.listRecentPayments((args.limit as number) ?? 20)
    default:
      throw new Error(`unknown tool ${name}`)
  }
}

5. Initiate a controlled payment

When the agent needs a paid resource, it calls stableops_x402_fetch. The SDK requests the resource first; on an HTTP 402 it parses the x402 quote, creates a payment intent, runs the full policy-and-signing loop, retries with the payment proof, and returns the result:

const result = await agent.x402Fetch(required('X402_RESOURCE_URL'), {
  idempotencyKey: 'quickstart:x402-resource:1', // reuse when retrying this purchase
})

if (result.status === 'paid') {
  const data = await result.response.json() // the paid resource
} else if (result.status === 'not_required') {
  const data = await result.response.json() // the resource is not paid
} else if (result.status === 'awaiting_approval') {
  // Manual approval was triggered: keep result.intentId and resume once approved
}
status: 'paid' means the paid HTTP request produced a reliable response. It does not prove that the control plane has confirmed settled. Query agent.getPayment(result.intentId) for current state. On settlement_unknown, reconcile the original Intent and never create a replacement Intent, authorization, or idempotency key.

When approval is required

awaiting_approval is not a failure; the policy is doing its job. Prefer the console approvals page. The client mode below is only for console code that already has a StableOps sign-in session. Its accessToken must come from the current organization-administrator session; it is short-lived and must not be stored as an environment secret:

async function approveFromDashboardSession(
  accessToken: string,
  approvalId: string,
) {
  const dashboard = new StableOpsAgentPayments({
    accessToken,
    environment: 'sandbox',
    baseUrl: required('STABLEOPS_API_URL'),
  })
  await dashboard.approvals.approve(approvalId, 'Routine data purchase within budget')
}

The management client uses a management API key and cannot decide approvals. Do not configure apiKey and accessToken on the same client. Do not cache the administrator token or give it to the Agent runtime.

After approval, load the saved Intent ID and resume the payment with that same ID to complete signing and settlement:

const approvedIntentId = 'pint_...' // Load the ID saved from awaiting_approval.
const resumed = await agent.x402Fetch(
  required('X402_RESOURCE_URL'),
  { resumeIntentId: approvedIntentId },
)

What you just did

  • Registered and bound the agent's default payment wallet, keeping the private key local.
  • Wrote and activated an explicit spending policy that draws the line between auto-approval, manual approval, and outright rejection.
  • Issued the Agent Key the runtime holds exclusively, and deployed a signer that holds its own private key.
  • Ran a full x402 payment through complete policy, budget, and approval controls.

Next: use the management SDK to put the configuration in infrastructure code, switch to AWS KMS with the signer, or connect Webhooks to track intent, approval, and settlement status in real time. Before moving to mainnet, verify its contract, decimals, payment method, and gas requirements in the introduction's supported scope.

How is this guide?

Last updated

On this page