Skip to content

Payment policy

A PaymentPolicy is the spend leash on an autonomous client. You set it once at construction, and from then on every 402 the client meets is checked against it before any on-chain send. A payment that breaches the policy is refused with PaymentDeclinedError and no funds move — so a server you don’t control cannot drain the wallet.

The enforcement is local and the SDK is the judge. A cap is measured against the token’s true decimals (the SDK’s own, via the driver) — never the server-stated extra.decimals — so a malicious server can’t slip past a maxAmount by claiming a cheap-looking amount.

Pass policy to the PipRailClient. With one set, the client only spends inside the lines you draw:

import { PipRailClient } from '@piprail/sdk'
const client = new PipRailClient({
chain: 'base',
wallet: { privateKey: process.env.AGENT_KEY! },
policy: {
maxAmount: '0.50', // per payment
maxTotal: '10.00', // lifetime, per asset
tokens: ['USDC'], // single-currency budget
hosts: ['*.example.com'], // only this domain
},
})

Omit policy entirely for the unguarded default. A policy is opt-in, but recommended for any headless agent — it is the difference between “the model can spend the wallet” and “the model can spend up to here.”

Every field is optional. An unset field places no limit; the checks you do set run in a pinned order, first-failure-wins, so the refusal reason is specific.

FieldTypeLimits
maxAmountstringPer-payment ceiling (human units, e.g. '0.10').
maxTotalstringLifetime ceiling for this client, per distinct asset.
chainsChainSelector[]Allowlist of chains the agent may pay on.
tokensstring[]Allowlist of token symbols, or the alias 'native'.
hostsstring[]Allowlist of hosts — exact or *. wildcard.
allowUnknownTokensbooleanPay a token the SDK can’t price? Default false.
ttlSecondsnumberSession time-to-live (see Time envelope).
expiresAtnumberAbsolute session deadline, epoch ms.
windowTotalstringRolling-window spend cap, per asset.
windowSecondsnumberWidth of that rolling window, in seconds.

maxAmount bounds a single payment; maxTotal bounds the running total over the life of the client. Both are human-readable strings, floored to the token’s true decimals.

policy: { maxAmount: '0.25', maxTotal: '5.00' }

maxTotal is tracked per distinct asset (network + asset), not summed across tokens — adding 1 USDC to 1 SOL is unit-meaningless without a price oracle, which the SDK deliberately doesn’t ship. Each token gets its own running cap. For a single-currency budget, pair maxTotal with a one-token allowlist:

policy: { maxTotal: '20.00', tokens: ['USDC'] } // 20 USDC, full stop

The running totals live in the spend ledger, which is process-scoped: every figure resets on restart.

Three allowlists narrow where and what the agent may pay. A 402 outside any of them is refused.

policy: {
chains: ['base', 'polygon'],
tokens: ['USDC', 'USDT'],
hosts: ['api.example.com', '*.trusted.dev'],
}
  • chains — string entries match the configured selector (an EVM preset like 'base' or a family name like 'solana'); object selectors (a viem Chain or { id, rpcUrl }) match by resolved network id.
  • tokens — matched against the token’s true symbol. The special value 'native' is a chain-agnostic alias for the chain’s native coin — it matches ETH, BNB, TRX, XLM, and so on without naming the ticker, mirroring the merchant-side token: 'native'.
  • hosts — exact (api.example.com) or wildcard (*.example.com, which also matches the bare apex example.com).

A token the SDK can’t recognise has no verifiable decimals, so PipRail can’t safely measure it against a cap. By default such a payment is refused — even with no other limit set — with the typed code UNKNOWN_TOKEN.

policy: { allowUnknownTokens: true } // explicit, opt-in risk

Set allowUnknownTokens: true to trust the server-stated decimals and pay anyway. This is the one knob that loosens the guard rather than tightening it; leave it off unless you mean it.

Four more fields put the policy on a clock. ttlSeconds and expiresAt set a session deadline after which every payment is refused regardless of amount; windowTotal + windowSeconds add a rolling rate limit on top of maxTotal. They have their own page:

policy: { ttlSeconds: 3600, windowTotal: '1.00', windowSeconds: 60 }

The rolling window requires both fields together — setting one without the other is a config error the client rejects at construction. See Time envelope for the full treatment.

You don’t have to attempt a payment to learn whether the policy would allow it. Both the read-only check and the live quote carry the verdict, so an agent can branch on it. quote() returns null when the URL isn’t payment-gated, so null-guard it:

const url = 'https://api.example.com/report'
const quote = await client.quote(url)
if (!quote) {
// not payment-gated — nothing to check
} else if (!quote.withinPolicy) {
console.log(quote.policyReason) // human-readable
console.log(quote.policyCode) // typed: 'MAX_AMOUNT' | 'CHAIN' | …
}
// → quote.policyCode is a PolicyDenyCode (see the table below) | undefined when within policy

planPayment() folds the same verdict into its per-rail analysis — a policy breach shows as the OUTSIDE_POLICY blocker (or OUTSIDE_WINDOW for the time envelope). And client.budget() reports the remaining allowance per asset plus any time leash, so a Mode-A agent can see how much room it has left.

When the policy refuses, the typed policyCode says exactly which guard fired — no prose-parsing required. This is the PolicyDenyCode enum, carried on quote.policyCode (and re-exposed via the testable evaluatePolicy() core).

CodeGuard
CHAINChain not in chains.
HOSTHost not in hosts.
UNKNOWN_TOKENUnrecognised token and allowUnknownTokens is off.
TOKENSymbol not in tokens.
MAX_AMOUNTPayment exceeds maxAmount.
MAX_TOTALPayment would push the per-asset total past maxTotal.
SESSION_EXPIREDThe session deadline has passed.
WINDOW_TOTALPayment would exceed windowTotal within the window.

Checks run in this order — session expiry → chains → hosts → unknown-token → tokens → maxAmount → maxTotal → windowTotal — with the first failure winning. Expiry is checked first because it’s session-global: an expired session always reports expiry, not whichever cap also happens to fail.

When a payment actually breaches the policy, the client throws PaymentDeclinedError before any on-chain send.code is always 'PAYMENT_DECLINED', and .reasonCode is a typed DeclineReasonCode an agent can branch on without parsing the message.

import { PaymentDeclinedError } from '@piprail/sdk'
const url = 'https://api.example.com/report'
try {
const res = await client.fetch(url)
// → paid (within policy) and the gated response is returned
console.log(res.status)
} catch (err) {
if (err instanceof PaymentDeclinedError) {
// No funds moved. Branch on the typed reason — never re-pay on a terminal code.
switch (err.reasonCode) {
case 'SESSION_EXPIRED': // TERMINAL — restart/extend the session, don't retry
case 'APPROVAL': // TERMINAL — an onBeforePay hook said no
console.error('refused, do not retry:', err.message)
break
case 'BUDGET': // lifetime maxTotal hit for this asset
case 'OUTSIDE_WINDOW': // rolling windowTotal exhausted — may clear later
case 'POLICY': // a chain/host/token/per-payment cap
default:
console.error('declined by policy:', err.message)
}
} else {
throw err
}
}

The policy is mechanical; for human-in-the-loop or custom per-payment logic, add onBeforePay. It runs after the policy passes but before any send, receiving the priced quote; return false to refuse (the client throws PaymentDeclinedError with reasonCode: 'APPROVAL' and no funds move). It may be sync or async.

const client = new PipRailClient({
chain: 'base',
wallet: { privateKey: process.env.AGENT_KEY! },
policy: { maxAmount: '0.50' },
// amountFormatted is a string — compare it as a number, not lexicographically.
onBeforePay: (quote) => Number(quote.amountFormatted) <= 0.1,
})