USDC and EURC payments for bots via the x402 protocol. One dependency (viem), typed everything.
- One dependency (
viem), fully typed - Simple API — register your bot and make payments in 2 lines of code
- Network support — Base, Optimism, Arbitrum One, Polygon PoS mainnets + Base Sepolia testnet (EIP155)
- Mock mode for testing without real transactions
- MCP integration — works with AI agent frameworks via
paybot-mcp - Self-hostable facilitator service
PayBotClient → Facilitator (x402) → On-chain USDC (EIP-3009)
The SDK wraps payment logic for bots, handling registration, payment execution, and network configuration. Developers can use the hosted facilitator at api.paybotcore.com or run their own.
npm install paybot-sdkimport { PayBotClient } from 'paybot-sdk';
const client = new PayBotClient({
apiKey: 'pb_test_...',
botId: 'my-bot',
facilitatorUrl: 'https://api.paybotcore.com',
});
// Register your bot
await client.register();
// Make a payment
const result = await client.pay({
resource: 'https://api.example.com/data',
amount: '0.01',
payTo: '0x1234...abcd',
});
console.log(result.success, result.txHash);Automatically pay for HTTP 402 responses:
import { createX402Handler } from 'paybot-sdk';
const handler = createX402Handler({
apiKey: 'pb_test_...',
botId: 'my-bot',
maxAutoPay: '1.00', // Max USD per auto-payment
});
// If the server returns 402, PayBot pays and retries automatically
const response = await handler.fetch('https://api.example.com/paid-endpoint');
const data = await response.json();When the server places a payment in its approval band (a borderline amount
that needs a human decision), the auto-handler does not block and does not
throw — it returns a 202 response carrying the pending result, so your agent
stays responsive:
const response = await handler.fetch('https://api.example.com/paid-endpoint');
if (response.status === 202) {
const pending = await response.json(); // { status: 'pending_approval', approvalId, … }
// Opt-in: wait for the human to decide (or carry on and check back later).
const final = await handler.client.waitForApproval(pending.approvalId);
}Pending vs. over-ceiling — two distinct paths. The client-side
maxAutoPayceiling still throws (your bot saying "I will never auto-pay this much"). A server-sidepending_approvalis a returned result (the server saying "a human should look at this"). They never collapse into one.
When PayBotFin's policy places a payment in the approval band, pay() returns
a non-throwing pending result instead of settling — the money has not moved,
but the payment is paused awaiting a human, not failed:
const result = await client.pay({ resource, amount: '500', payTo });
if (result.status === 'pending_approval') {
// success is false (money didn't move) but this is a PAUSE, not a failure.
console.log('Awaiting approval:', result.approvalId, 'expires', result.expiresAt);
// Opt-in: block until a human approves/denies, or the request times out.
const final = await client.waitForApproval(result.approvalId!, {
timeoutMs: 15 * 60_000, // default: 15 min (aligned to the server TTL)
intervalMs: 3_000, // default: 3 s poll cadence
});
if (final.success) {
console.log('Approved + settled:', final.txHash);
} else if (final.status === 'denied') {
console.log('Denied or expired:', final.error);
} else if (final.status === 'pending_approval') {
// Local timeout — the SERVER record is still live; the SDK timing out never
// cancels the approval. You may call waitForApproval(...) again later.
}
}Safe degradation: a consumer that only reads result.success keeps working
unchanged — a pending payment looks like success: false ("not done yet"), with
no new crash path. waitForApproval() itself never throws: poll errors and
local timeouts are returned as PaymentResults, not exceptions.
Pass a wallet private key to sign actual on-chain USDC transfers:
const client = new PayBotClient({
apiKey: 'pb_...',
botId: 'my-bot',
walletPrivateKey: '0x...', // Signs EIP-3009 TransferWithAuthorization
});PayBot enforces progressive trust levels that govern what your bot can do:
| Level | Name | Per-Tx Limit | Daily Limit |
|---|---|---|---|
| 0 | Suspended | $0 | $0 |
| 1 | New | $1 | $10 |
| 2 | Basic | $10 | $100 |
| 3 | Verified | $100 | $1,000 |
| 4 | Trusted | $1,000 | $10,000 |
| 5 | Premium | $10,000 | $100,000 |
| Method | Description |
|---|---|
client.pay(request) |
Execute a payment (verify + settle); returns a pending result if the server requires human approval |
client.waitForApproval(approvalId, opts?) |
Poll a pending approval until settled/denied/expired (or local timeout). Never throws |
client.register(trustLevel?) |
Register bot with facilitator |
client.balance() |
Get trust status and remaining budget |
client.history(limit?) |
Get transaction history |
client.setLimits(limits) |
Update spending limits |
client.health() |
Check facilitator health |
The package ships a paybot command — a thin wrapper over PayBotClient. No
install needed beyond npx:
npx paybot --help(Or install globally / as a dependency: npm i -g paybot-sdk then paybot ....)
Every command resolves config with precedence flags > environment > error:
| Setting | Flag | Environment variable |
|---|---|---|
| API key | --api-key <key> |
PAYBOT_API_KEY |
| Bot id | --bot-id <id> |
PAYBOT_BOT_ID |
| Wallet key (for real payments) | (env only) | WALLET_PRIVATE_KEY |
| Facilitator URL | --facilitator-url <url> |
PAYBOT_FACILITATOR_URL |
Secrets (API keys, wallet keys) are never printed in full — any echoed value
is masked as prefix…suffix.
export PAYBOT_API_KEY="pb_test_..."
export PAYBOT_BOT_ID="my-bot"# Register this bot (optional initial trust level 0-5)
paybot register --bot-id my-bot --trust-level 2
# Show trust status + remaining budget
paybot balance
# Pay for a resource (amount is human-readable, e.g. 0.05)
paybot pay \
--resource https://api.example.com/data \
--amount 0.05 \
--pay-to 0xRecipient... \
--token USDC \
--idempotency-key order-123
# Check facilitator health
paybot health
# List supported networks (CAIP-2 + name)
paybot networks
# List supported tokens (optionally for one network)
paybot tokens
paybot tokens --network eip155:10| Command | Wraps |
|---|---|
paybot register [--trust-level <n>] |
client.register() |
paybot balance |
client.balance() |
paybot pay --resource <url> --amount <human> --pay-to <0x> [--token <SYM>] [--network <caip2>] [--idempotency-key <k>] |
client.pay() |
paybot health |
client.health() |
paybot networks |
getSupportedNetworks() |
paybot tokens [--network <caip2>] |
getSupportedTokens() |
Non-pay() methods throw PayBotApiError on failure:
import { PayBotApiError } from 'paybot-sdk';
try {
await client.balance();
} catch (err) {
if (err instanceof PayBotApiError) {
console.log(err.code); // 'NOT_FOUND'
console.log(err.statusCode); // 404
console.log(err.details); // { botId: 'unknown-bot' }
}
}pay() returns PaymentResult with success: false instead of throwing:
const result = await client.pay({ ... });
if (!result.success) {
console.log(result.error); // Human-readable message
console.log(result.errorCode); // 'TRUST_VIOLATION'
console.log(result.errorDetails); // { gate: 'SPENDING_ENVELOPE', ... }
}import { NETWORKS, getNetwork, getSupportedNetworks } from 'paybot-sdk';
// Available networks
console.log(getSupportedNetworks());
// ['eip155:8453', 'eip155:84532', 'eip155:10', 'eip155:42161', 'eip155:137']
// Get network details
const baseSepolia = getNetwork('eip155:84532');
console.log(baseSepolia?.name); // 'Base Sepolia'| Network | CAIP-2 | Chain ID | Type |
|---|---|---|---|
| Base Mainnet | eip155:8453 |
8453 | mainnet |
| Base Sepolia | eip155:84532 |
84532 | testnet |
| Optimism | eip155:10 |
10 | mainnet |
| Arbitrum One | eip155:42161 |
42161 | mainnet |
| Polygon PoS | eip155:137 |
137 | mainnet |
RPC URLs default to public endpoints (mainnet.optimism.io, arb1.arbitrum.io/rpc, polygon-rpc.com, …); override per network for production use.
Verify inbound webhooks from the facilitator (HMAC-SHA256, constant-time compare, replay-window guard). No extra dependency — uses Node's built-in crypto.
import { verifyWebhookSignature } from 'paybot-sdk';
app.post('/paybot/webhook', (req, res) => {
const valid = verifyWebhookSignature({
payload: req.rawBody, // raw string/Buffer, exactly as received
signature: req.headers['paybot-signature'], // 't=<unix_ts>,v1=<hmac_hex>'
secret: process.env.PAYBOT_WEBHOOK_SECRET,
tolerance: 300, // optional replay window in seconds (default 300)
});
if (!valid) return res.status(400).send('invalid signature');
// ...handle event
res.sendStatus(200);
});The signing string is `${t}.${payload}`; the same algorithm is implemented identically in the Python SDK, so a server-signed webhook verifies byte-for-byte in either runtime. signWebhookPayload({ payload, secret }) produces a header value for TS-based senders and tests.
Pass any OpenTelemetry-compatible tracer to emit spans around the payment lifecycle. When no tracer is supplied, telemetry is a zero-overhead no-op — @opentelemetry/api is not a dependency of this SDK.
import { trace } from '@opentelemetry/api';
const client = new PayBotClient({
apiKey: 'pb_...',
botId: 'my-bot',
walletPrivateKey: '0x...',
telemetry: { tracer: trace.getTracer('my-bot'), prefix: 'paybot.' },
});Spans emitted per pay(): paybot.client.pay, paybot.x402.sign, paybot.x402.challenge, paybot.x402.settle — with network, amount, bot_id, tx_hash, and success attributes. A returned payment failure is an OK span with success=false; only thrown errors become span ERROR + recordException.
Tracks the x402-foundation spec: v2 PAYMENT-REQUIRED / PAYMENT-SIGNATURE / PAYMENT-RESPONSE headers (the legacy Payment-Intent path still works), CAIP-2 network identifiers, and the upto (metered/usage) scheme alongside exact.
import { parseCaip2, isSupportedCaip2 } from 'paybot-sdk';
parseCaip2('eip155:8453'); // { namespace: 'eip155', reference: '8453' }
isSupportedCaip2('eip155:8453'); // true
// 'upto' authorizes a capture ceiling; the facilitator settles the actual usage <= max.
// X402Handler.validateUptoCapture(authorizedMax, captured) throws UPTO_OVERCHARGE if exceeded.Pay in USDC (default), EURC, or DAI. The signing domain is resolved per-token, so each token signs against its own contract. USDC defaults everywhere; the public surface is unchanged for existing USDC callers.
import { getToken, getSupportedTokens } from 'paybot-sdk';
getSupportedTokens(); // ['USDC', 'EURC', 'DAI']
getToken('EURC')?.symbol; // 'EURC'
getToken('DAI')?.decimals; // 18
await client.pay({
resource: 'https://api.example.com/data',
amount: '0.50',
payTo: '0x....',
token: 'EURC', // defaults to 'USDC'; unknown token → UNSUPPORTED_TOKEN
network: 'eip155:84532', // EURC testnet ships in the public registry
});Only (token, network) pairs verifiable against an official issuer source are
shipped in the public registry. A wrong contract address routes real funds to the
wrong contract, so unverifiable pairs are deliberately omitted rather than guessed.
The public registry carries only addresses that are safe to ship open-core — mainnet
addresses for regulated tokens (e.g. EURC mainnet) are operator-supplied at runtime
(see below), not hardcoded here.
| Token | Decimals | Base | Base Sepolia | Optimism | Arbitrum | Polygon | Issuer source |
|---|---|---|---|---|---|---|---|
| USDC | 6 | ✓ | ✓ | ✓ | ✓ | ✓ | Circle USDC addresses |
| EURC | 6 | override | ✓ | — | — | — | Circle EURC addresses |
| DAI | 18 | — | — | ✓ | ✓ | ✓ | MakerDAO / Sky |
override = the address is not shipped in the public registry; supply it at runtime
via tokenAddressOverrides (see below).
Not currently supported: PYUSD (Paxos — Ethereum + Solana only) and RLUSD (Ripple — Ethereum + XRPL only) are not deployed on any network in this registry, so they are not registered.
Token contract addresses ship with their official issuer source cited inline in
src/networks.ts. Re-verify each address against the cited source before mainnet use.
The public registry ships only addresses that are safe to distribute open-core
(e.g. testnet deployments). Mainnet addresses for regulated tokens are not
hardcoded in the SDK — inject them at runtime via tokenAddressOverrides
(symbol → caip2Network → address):
const client = new PayBotClient({
apiKey: 'pb_...',
botId: 'my-bot',
tokenAddressOverrides: {
EURC: { 'eip155:8453': '0x...' }, // your EURC mainnet contract address
},
});Address resolution precedence: explicit PaymentRequest.tokenContract →
tokenAddressOverrides[symbol][network] → the public registry → otherwise a
PaymentResult failure with code TOKEN_ADDRESS_NOT_CONFIGURED. This keeps the
SDK from signing against a wrong or absent address when a mainnet token is not
configured.
pay() still returns PaymentResult (never throws). The other methods throw a typed hierarchy you can instanceof-switch on — all subclasses remain instanceof PayBotApiError for backward compatibility.
import {
PayBotError, // abstract root
PayBotApiError, // HTTP-level (unchanged)
PayBotNetworkError, PayBotTimeoutError, PayBotAuthError,
PayBotPolicyError, // trust/AML/daily-limit
PayBotSignatureError, PayBotSettlementError,
} from 'paybot-sdk';
try {
await client.balance();
} catch (err) {
if (err instanceof PayBotPolicyError) { /* trust/limit gate */ }
else if (err instanceof PayBotAuthError) { /* re-auth */ }
}Pass an idempotencyKey to pay() / register() — sent as X-Idempotency-Key and deduped in a per-client LRU so a retried call doesn't double-bill.
await client.pay({ resource, amount: '0.01', payTo, idempotencyKey: 'order_42_attempt_1' });
// A second call with the same key returns the cached result with no network round-trip.Run many bots in one process from shared operator/transport config, each with its own signing key, under an optional shared daily spend ceiling.
import { PayBotClientPool } from 'paybot-sdk';
const pool = new PayBotClientPool({
apiKey: 'pb_...',
operatorId: 'op_1',
sharedDailyLimitUsd: 500, // optional treasury across all bots
});
pool.addBot({ botId: 'bot-1', walletPrivateKey: '0x...' });
pool.addBot({ botId: 'bot-2', walletPrivateKey: '0x...' });
// payAs() blocks over-treasury BEFORE any network call (errorCode 'TREASURY_EXCEEDED')
const result = await pool.payAs('bot-1', { resource, amount: '12.00', payTo });
pool.remainingTreasuryUsd(); // 488 after a successful $12 spendMicropaymentEngine queues many sub-cent payments and settles them as one signed batch, so per-payment gas is amortized across the group. Auto-settle fires when the batch window closes or the count/total thresholds are reached.
import { MicropaymentEngine } from 'paybot-sdk';
const engine = new MicropaymentEngine({
walletPrivateKey: '0x...',
batchWindowMs: 60_000, // default 60s window
minPaymentCount: 100, // auto-settle thresholds
minTotalUsd: 1.0,
});
const id = await engine.queuePayment('0xRecipient...', '0.001'); // returns paymentId
const batch = await engine.batchPayments([id]); // signed BatchedSettlement
engine.getQueueStatistics(); // BatchStatisticspaybot is a clean x402 settlement engine, so a Google AP2 (A2A x402-extension) mandate can settle through it directly. MPP (Stripe/Tempo) is still preview — the SDK ships only a detect-and-route capability seam, not a full client.
import { Ap2Adapter, detectMppCapability } from 'paybot-sdk';
const ap2 = new Ap2Adapter(handler); // handler: X402Handler
if (ap2.validateMandate(mandate).valid) {
const receipt = await ap2.settle(mandate); // signs + submits via x402
}
detectMppCapability(responseHeaders); // { supported, mode: 'detect-only'|'none', specVersion? }
// createMppSeam().settle(...) throws MPP_NOT_IMPLEMENTED (501) — full MPP deferred until GAThe AP2 adapter settles the payment; it does not verify the AP2 verifiable-credential signature — that stays in the mandate issuer's trust domain.
A Python port lives in packages/python (paybot-sdk on PyPI, >=3.10). Mirrors the TS client surface, real EIP-3009 signing via eth-account, and the identical webhook verification contract.
from paybot_sdk import PayBotClient, PayBotConfig, PaymentRequest, verify_webhook_signature
client = PayBotClient(PayBotConfig(api_key="pb_...", bot_id="my-bot", wallet_private_key="0x..."))
result = await client.pay(PaymentRequest(resource="https://api.example.com/data",
amount="0.01", pay_to="0x...."))For AI agent frameworks, use paybot-mcp which wraps this SDK as an MCP server.
Legend: ✅ shipped · 🟡 partial · 🔭 deferred (intentional) · ⬜ gap
Capability map — what the SDK can do today:
Roadmap ahead — the phased plan:
Core rail (hardened): PayBotClient (pay/balance/history/setLimits/register/health/commission/API-keys) · x402 auto-handler · EIP-3009 signing · MicropaymentEngine · trust levels 0–5 · commission accounting · paybot402() middleware · self-hostable facilitator · CI hardening (CodeQL, OSV, 80% coverage gate, SHA-pinned actions).
| Shipped | Gap ID |
|---|---|
✅ x402 v2 conformance — upto scheme, PAYMENT-* headers, CAIP-2 helpers |
T1.1 + new |
| ✅ Webhook signature verification (TS + Python, byte-identical HMAC) | T1.2 |
✅ Idempotency keys (X-Idempotency-Key + LRU dedupe) |
T1.3 |
✅ Error taxonomy (PayBotError + 6 typed subclasses) |
T1.4 |
| ✅ OpenTelemetry hooks (opt-in, zero new deps) | T1.5 |
| ✅ Multi-bot pool + spend treasury | T1.6 |
| ✅ EURC + token registry (per-token EIP-712 domains) | T2.2 |
| ✅ AP2 settlement adapter · 🔭 thin MPP capability seam (deferred to GA) | T2.3 |
| ✅ Python SDK 0.1.0 (real EIP-3009 signing) | T3.2 (partial) |
Tier 1 (credibility blockers) is 100% complete (T1.1–T1.6). Test posture: 331 TS tests / 98.66% coverage · 52 Python tests.
- ⬜ Network expansion (T2.1) — still Base + Base Sepolia only; add Optimism / Arbitrum / Polygon.
- ⬜ CCTP V2 cross-chain receive — ⏰ time-sensitive: Circle CCTP V1 deprecates 2026-07-31.
- ⬜ Refund + reversal helpers (T2.4) · ⬜ Streaming subscriptions (T2.5) · ⬜ Wallet-connect bridge (T2.6)
- 🟡 Token breadth — USDC + EURC done; PYUSD / RLUSD / DAI not added (operator-gated).
- 🟡 Language ports (T3.2) — Python runtime shipped (middleware/x402-handler unported); Go / Rust not started.
- ⬜ Framework ports (T3.1) (Hono/Next/NestJS/FastAPI/Django) · ⬜ CLI (T3.4) · ⬜ Examples + tutorial site (T3.5) · ⬜ More MCP tools (T3.3) · ⬜ L402 shim (T3.6)
- 🔭 Full MPP (T2.3) — deliberately deferred; Stripe/Tempo MPP is still preview and shares no signing code with EIP-3009. Detect-only seam ships now.
Prioritized by internal gap severity × external leverage (EU-bank credibility, live distribution channels, hard deadlines).
Phase A — Near-term (rail credibility):
- Network expansion (Optimism + Arbitrum + Polygon) — closes the biggest surface gap vs. Coinbase/Circle/Crossmint.
- CCTP V2 cross-chain receive — ⏰ hard deadline (CCTP V1 dies 2026-07-31).
- Refund + reversal helpers — table-stakes for real commerce.
- Token breadth (PYUSD/RLUSD/DAI, operator-gated).
Phase B — Mid-term (agent-economy surface): streaming subscriptions · CLI · framework ports (Hono/Next/FastAPI first) · examples + tutorial site · wallet-connect bridge.
Phase C — Strategic / opportunistic: full MPP (on GA) · more language ports (Go/Rust) · L402/Lightning shim · more MCP tools.
Strategic posture: the moat is self-hosted, non-custodial, MIT, trust-layer-in-the-SDK — which custodial/portal-locked rivals (Coinbase Agentic Wallets, Circle, Crossmint, Payman) structurally cannot copy. Being a clean x402 settlement engine makes paybot AP2-pluggable and AgentCore-compatible today — so MPP can wait for GA.
Use the hosted facilitator at api.paybotcore.com — no setup needed, ready to go:
const client = new PayBotClient({
apiKey: 'pb_test_...',
botId: 'my-bot',
facilitatorUrl: 'https://api.paybotcore.com', // ← Hosted
});For enterprise bots or custom networks, deploy your own PayBot facilitator with Docker (5 minutes):
git clone https://github.com/RBKunnela/paybot-core.git
cd paybot-core
docker compose up -dThen configure your bot:
const client = new PayBotClient({
apiKey: 'pb_dev_...',
botId: 'my-bot',
facilitatorUrl: 'http://localhost:3000', // ← Self-hosted
});Quick start guide: See SELF_HOSTING.md in this repository.
Full deployment guide: See DEPLOYMENT.md in paybot-core repository.
We take security seriously. All PRs are reviewed by an army of AI agents from different specializations (@dev, @qa, @architect, @security, @devops) before acceptance. This ensures code quality, correctness, security validation, and architectural alignment.
See CONTRIBUTING.md for details.

