Developer Docs
The x402 payment rail on Solana — the handshake, the schemes, the API, the SDK, the CLI, and the on-chain program.
Introduction
HTTP 402 "Payment Required" is the status code the web reserved and never shipped — formally reserved in RFC 9110 §15.4.3. x402 revives it as a real payment handshake: a server answers a request with 402 and a machine-readable price, the client pays, and the same request is replayed and served.
Otomat is Solana's x402 payment rail for machine-to-machine, per-call APIs. It has three moving parts:
- The wall — middleware / a proxy that answers unpaid requests with a 402 challenge and serves the resource once paid.
- The rail — a stateless facilitator that verifies a signed Solana payment and settles it on-chain, plus the
otomat_railAnchor program. - The wallet — an agent wallet kit and a browser extension that pay 402s automatically under a spend policy.
FwFC1nDo5r224uXDeTMc2X8FhXuiz9CqBF7tg3trEVhN and settles in USDC (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v). It has not been formally audited. Use small amounts and treat agent hot wallets as disposable.Quickstart
Two roles: the seller who prices a URL, and the buyer (usually an agent) who pays it.
1. Monetize a URL
POST an origin URL and a price to the endpoints API. You get back a proxyUrl under /x/{id} that enforces payment.
# Turn any URL into a paid endpoint. Returns a proxy URL you can share.
curl -X POST https://api.otomat.fun/api/endpoints \
-H 'content-type: application/json' \
-d '{
"originalUrl": "https://your-origin.example/report",
"price": "0.002",
"payTo": "YOUR_SOLANA_ADDRESS",
"description": "Premium report",
"priceAsset": "USDC"
}'
# 201 Created
# {
# "id": "V1StGXR8_Z",
# "proxyUrl": "https://api.otomat.fun/x/V1StGXR8_Z",
# "middlewareSnippet": "...",
# "endpoint": { "id": "V1StGXR8_Z", "price": "0.002", "priceAsset": "USDC", ... }
# }2. Pay it
With the CLI:
npm i -g otomat-cli
# Generate a local hot wallet (~/.otomat/keypair.json)
otomat wallet keygen
# Fund it with a little USDC + SOL, then pay the endpoint.
otomat pay https://api.otomat.fun/x/V1StGXR8_Z --budget '$0.01' --jsonOr from code with the SDK:
import { Connection, Keypair } from '@solana/web3.js';
import { OtomatClient } from '@otomat/sdk';
const client = new OtomatClient({
payer: Keypair.fromSecretKey(secret),
connection: new Connection('https://api.mainnet-beta.solana.com'),
facilitatorUrl: 'https://facilitator.otomat.fun',
});
// client.fetch() transparently handles the 402: it decodes the price,
// signs a Solana payment, replays the request, and returns the paid response.
const res = await client.fetch('https://api.otomat.fun/x/V1StGXR8_Z');
const data = await res.json();Prefer to keep your own server and just price a route? Skip the proxy and drop in one line of middleware.
The x402 protocol
The handshake rides on three headers. Each carries a base64(JSON) v2 document. Version-1 legacy headers (X-PAYMENT, X-PAYMENT-RESPONSE) are still accepted on the request side.
PAYMENT-REQUIREDThe 402 challenge (base64 PaymentRequirements)PAYMENT-SIGNATUREThe paid retry (base64 PaymentPayload)PAYMENT-RESPONSEThe settled resource (base64 SettleResult)On a 402 the server also sets WWW-Authenticate: x402. On the paid 200 it echoes the endpoint id in x-otomat-endpoint. The whole exchange:
# 1. Unpaid request gets a 402 challenge
curl -i https://api.otomat.fun/x/V1StGXR8_Z
# HTTP/1.1 402 Payment Required
# WWW-Authenticate: x402
# PAYMENT-REQUIRED: eyJ4NDAyVmVyc2lvbiI6Miwi... (base64 of the JSON body)
# content-type: application/json
#
# { "x402Version": 2, "error": "PAYMENT-SIGNATURE header is required", "accepts": [ ... ] }PAYMENT-REQUIRED body
The 402 body is the PaymentRequirements object (also base64-encoded into the header). amount is in atomic units — 2000 = 0.002 USDC.
{
"x402Version": 2,
"error": "PAYMENT-SIGNATURE header is required",
"resource": {
"url": "https://api.otomat.fun/x/V1StGXR8_Z",
"description": "Premium report",
"mimeType": "application/json"
},
"accepts": [
{
"scheme": "exact",
"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
"amount": "2000",
"asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"payTo": "3Kp...payee",
"maxTimeoutSeconds": 60,
"extra": { "feePayer": "Fee...facilitator", "destinationAta": "Ata..." }
}
],
"extensions": {}
}PAYMENT-SIGNATURE payload
The client picks one item from accepts, builds a payment for it, and replays the request with this payload base64-encoded in the PAYMENT-SIGNATURE header.
{
"x402Version": 2,
"scheme": "exact",
"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
"accepted": { "scheme": "exact", "amount": "2000", "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "payTo": "3Kp...payee", "...": "..." },
"payload": { "transaction": "<base64 partially-signed VersionedTransaction>" },
"extensions": {}
}
# The client sends this as: PAYMENT-SIGNATURE: base64(JSON.stringify(payload))Settle, then serve
The proxy is settle-first: it verifies the payment, settles it on-chain, and only then fetches the origin. A failed settlement never yields a resource. The settlement result comes back in PAYMENT-RESPONSE.
# On the paid retry the origin body is returned with:
# PAYMENT-RESPONSE: base64(JSON.stringify(SettleResult))
# x-otomat-endpoint: V1StGXR8_Z
{
"success": true,
"payer": "9Wz...payer",
"transaction": "5xY...signature",
"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
}Exact scheme (SVM)
The default scheme is exact: a one-shot SPL token transfer of a fixed amount. The client builds a partially-signed Solana VersionedTransaction where the facilitator is the fee payer — so the buyer pays zero SOL for gas.
VersionedTransaction (v0 message, no address-table lookups)
feePayer = staticAccountKeys[0] = facilitator // gasless for the payer
signatures[0] = <empty> // facilitator fills at settle
instructions:
[0] ComputeBudget.SetComputeUnitLimit units = 20000 (max 40000)
[1] ComputeBudget.SetComputeUnitPrice microLamports = 1 (max 5)
[2] SPL/Token-2022 TransferChecked
disc = 12, amount = u64 LE, decimals = u8
accounts: [0] source(payer ATA) [1] mint [2] destination(payTo ATA) [3] authority(payer)
[3..5] optional, whitelist only: Memo | Lighthouse | ComputeBudget
total instructions: 3 to 6The facilitator re-derives everything and rejects anything that does not match to the byte. It fills signatures[0] (its own fee-payer slot), broadcasts, and confirms. The compute-budget ceilings (40000 units / 5 micro-lamports) and the strict instruction whitelist keep the payer from smuggling extra effects into a signed transaction.
import { buildExactPayload } from '@otomat/sdk';
// Build the exact-scheme payload yourself (client.fetch() does this for you).
const payload = await buildExactPayload({
requirement, // one item from accepts[]
payer, // Keypair | wallet adapter (signTransaction)
connection, // @solana/web3.js Connection
});
// payload.payload.transaction is the base64 partially-signed tx.solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp, devnet solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1. Both SPL Token and Token-2022 mints are supported. Every rejection reason is listed under Errors.Payment channels
For high-frequency streams, the otomat-channel scheme amortizes on-chain cost. The payer escrows a deposit once, then signs a running tally off-chain; only the final balance settles on-chain. It is the Lightning idea on Solana.
The off-chain check
Each incremental payment is a signed check — a canonical 72-byte message the payer signs with Ed25519. The on-chain program reconstructs the exact same bytes before it will release funds.
check message (72 bytes) =
"otomat/v2/check|" // 16 bytes ASCII domain prefix (note the trailing '|')
|| channelPda.toBytes() // 32 bytes raw pubkey, NOT the base58 string
|| u64_le(cumulativeAmount) // 8 bytes
|| u64_le(nonce) // 8 bytes
|| i64_le(expiry) // 8 bytes
# Signed off-chain with Ed25519 by the channel payer. The on-chain program
# reconstructs the same 72 bytes and verifies via the Ed25519 native program.Lifecycle
Five instructions move a channel through its life: open_channel escrows the deposit; close_channel lets the facilitator settle the latest check; dispute_channel (permissionless) submits a higher-nonce check and opens a challenge window; resolve_dispute settles once that window elapses; and timeout_reclaim lets the payer recover the un-spent deposit after expiry.
# Escrow a deposit and open a channel to a payee.
otomat channel open --payee <PAYEE_PUBKEY> --deposit 1.00 --expiry 86400
# Later, the facilitator closes it on-chain with the latest signed check.
otomat channel close --channel <CHANNEL_PDA> --cumulative 0.42 --nonce 17
otomat channel status --channel <CHANNEL_PDA>
otomat channel listEvents
The program emits seven events; index these to reconstruct channel and receipt history.
API reference
The indexer service serves the endpoint registry, receipts, merchant dashboards, and the paid proxy. Base URL https://api.otomat.fun. All responses are JSON unless noted. Reads are unauthenticated; the only signed call is merchant onboarding.
Endpoints (monetization)
/api/endpointsoriginalUrl (required, URL), price (required, positive decimal string), payTo (required, Solana pubkey), description?, mimeType?, priceAsset? (USDC | SOL), scheme? (exact | otomat-channel | otomat-stream). Returns 201 with id, proxyUrl, middlewareSnippet, endpoint./api/endpointsmerchant?, limit? (1–200, default 60), offset?, includeInactive?. Returns { endpoints, count, limit, offset }; each endpoint carries its proxyUrl./api/endpoints/:id{ endpoint, proxyUrl, middlewareSnippet }, or 404 not_found.Proxy (the paywall)
/x/:idPAYMENT-REQUIRED + WWW-Authenticate: x402 and the requirements body. With a valid PAYMENT-SIGNATURE, verifies + settles, then streams the origin body withPAYMENT-RESPONSE and x-otomat-endpoint. Errors: 404 endpoint_not_found, 502 facilitator_unavailable / upstream_unreachable.Receipts
/api/receiptsmerchant? (filters by payee), payer?, scheme?, limit? (1–200, default 50), offset?. Returns { receipts, count, limit, offset }./api/receipts/:signature{ signature, receipt, receipts } or 404 not_found.Merchants & dashboard
/api/merchantspayTo, profile fields, a message that must contain payTo, and a base64 Ed25519 signature over it (64 bytes). Returns 201 { merchant }. Errors: 400 invalid_message, 401 invalid_signature./api/merchants/:pubkeynot_found if unknown./api/dashboard/:merchantvolumeUsd (day/week/month/total), proxyStats, topEndpoints (top 5), recentActivity (last 10). Unknown merchant returns 200 with merchant: null.Metrics, live feed & health
/api/metricsendpoints, settlements, volumeUsd, p50FinalityMs, x402Compliant, anchorVersion, network, usdcMint. Cached ~5s./api/livefeed/streamhello event, then settle / channel_close / receipt / bond / slash events, with a ping keepalive every 15s./healthFacilitator
The facilitator (https://facilitator.otomat.fun) is the stateless verifier and settler behind the proxy. You rarely call it directly, but it exposes:
/verify/settle/supportedcurl https://facilitator.otomat.fun/supported
# { "kinds": [ { "scheme": "exact", "network": "solana:mainnet" } ] }SDK — @otomat/sdk
The TypeScript SDK builds payments and drives the 402 handshake. The core is OtomatClient, whose fetch() is a drop-in for fetch that pays transparently.
Constructor options: payer (Keypair or wallet adapter, required), connection (required), facilitatorUrl?, network? (defaults to mainnet), fetchImpl?. Methods: fetch(url, init?), buildExactPayload(requirement), buildChannelPayload(check).
import { Connection, Keypair } from '@solana/web3.js';
import { OtomatClient } from '@otomat/sdk';
const client = new OtomatClient({
payer: Keypair.fromSecretKey(secret),
connection: new Connection('https://api.mainnet-beta.solana.com'),
facilitatorUrl: 'https://facilitator.otomat.fun',
});
// client.fetch() transparently handles the 402: it decodes the price,
// signs a Solana payment, replays the request, and returns the paid response.
const res = await client.fetch('https://api.otomat.fun/x/V1StGXR8_Z');
const data = await res.json();MCP — pay across tool calls
The @otomat/sdk/mcp entry point pays for Model Context Protocol tools. withPayment(client, { account }) wraps any MCP client so a PAYMENT-REQUIRED tool result is paid and retried automatically. On the server, paidTool(server, config) charges per call.
import { withPayment } from '@otomat/sdk/mcp';
import { OtomatClient } from '@otomat/sdk';
const account = new OtomatClient({ payer, connection });
// Wrap any MCP client. When a tool answers PAYMENT-REQUIRED, the wrapper
// builds an exact payload from `account` and replays callTool automatically.
const paid = withPayment(mcpClient, { account });
const result = await paid.callTool({ name: 'premium_search', arguments: { q: 'otomat' } });import { paidTool } from '@otomat/sdk/mcp';
// Server side: charge per tool call on your MCP server.
paidTool(server, {
name: 'premium_search',
price: '$0.002',
payTo: 'YOUR_SOLANA_ADDRESS',
schema: z.object({ q: z.string() }),
handler: async ({ q }) => ({ content: [{ type: 'text', text: search(q) }] }),
});CLI — otomat-cli
Install otomat-cli for the otomat binary: "Insert coin. Receive response."
npm i -g otomat-cli| Command | Options |
|---|---|
otomat init | --wallet <new|path>, --facilitator <url>, --network <net>, --api <url> |
otomat wallet keygen | --force, --outfile <path> |
otomat wallet show | (prints pubkey, network, SOL + USDC balances) |
otomat pay <url> | --wallet <path>, --budget <amount> (default $0.10), --network <net>, --facilitator <url>, --json |
otomat monetize <url> | --price <amount> (required), --description <text>, --payTo <pubkey>, --api <url> |
otomat serve | --price <amount> (required), --port <n> (default 3000), --upstream <url>, --payTo <pubkey>, --network, --facilitator |
otomat channel open | --payee <pubkey>, --deposit <amount>, --expiry <seconds>, --network, --facilitator, --wallet |
otomat channel close | --channel <pda>, --cumulative <amount>, --nonce <n>, --network, --wallet |
otomat channel status | --channel <pda>, --network |
otomat channel list | --json |
otomat receipts | --limit <n>, --payer <pubkey>, --api <url>, --json |
Middleware — @otomat/middleware
Price a route on your own server with one line. Sub-path exports ship Express, Next (App Router), and Hono adapters, callable as otomat.express / otomat.next / otomat.hono.
import express from 'express';
import { otomat } from '@otomat/middleware';
// or: import { otomatExpress } from '@otomat/middleware/express';
const app = express();
// One line prices the route. Unpaid calls get 402; paid calls run the handler.
app.get(
'/report',
otomat.express({ price: '$0.002', asset: 'USDC', payTo: 'YOUR_SOLANA_ADDRESS' }),
(req, res) => res.json({ report: buildReport() }),
);
app.listen(3000);// app/api/report/route.ts
import { otomat } from '@otomat/middleware';
// or: import { otomatNext } from '@otomat/middleware/next';
export const GET = otomat.next(
{ price: '$0.002', asset: 'USDC', payTo: 'YOUR_SOLANA_ADDRESS' },
async () => Response.json({ report: buildReport() }),
);import { Hono } from 'hono';
import { otomat } from '@otomat/middleware';
const app = new Hono();
app.get(
'/report',
otomat.hono({ price: '$0.002', asset: 'USDC', payTo: 'YOUR_SOLANA_ADDRESS' }),
(c) => c.json({ report: buildReport() }),
);Config: price (required), payTo (required, or env OTOMAT_PAY_TO), asset (default USDC), network, facilitatorUrl, scheme (default exact), maxTimeoutSeconds (default 60), description, mimeType (default application/json), feePayer, x402Version (default 2), and the onSettled / onError hooks. Price accepts '$0.002', '0.002 USDC', atomic strings, or { amount, decimals }.
Agent wallet kit — @otomat/agent-wallet-kit
AgentWallet gives an autonomous agent a spend-limited wallet. Its fetch() and pay() pay 402s only within a SpendPolicy, throwing PolicyViolationError when a limit is crossed.
import { Connection, Keypair } from '@solana/web3.js';
import { AgentWallet, BudgetPolicy } from '@otomat/agent-wallet-kit';
const wallet = new AgentWallet({
keypair: Keypair.fromSecretKey(secret),
connection: new Connection('https://api.mainnet-beta.solana.com'),
policy: BudgetPolicy({ maxTotal: 5_000000n, maxPerCall: 100000n }), // 5 / 0.10 USDC
});
// Pays 402s within budget; throws PolicyViolationError when a limit is hit.
const res = await wallet.fetch('https://api.otomat.fun/x/V1StGXR8_Z');
console.log(wallet.getSpent()); // { total, byAsset }Construct with keypair + policy (both required), connection? (defaults to public mainnet RPC), facilitatorUrl?, fetchImpl?. Track spend with getSpent(); clear it with reset(). Policy factories BudgetPolicy, WhitelistPolicy, TimeWindowPolicy and composePolicies build the object below.
SpendPolicy fields
| Field | Type | Meaning |
|---|---|---|
maxTotal | { amount: bigint; asset } | Cumulative cap across all calls for that asset. |
maxPerCall | { amount: bigint; asset } | Per-call cap. |
perDomainLimit | Record<string, bigint> | Cumulative cap per origin domain. |
allowedDomains | string[] | Allowlist. A domain not listed is rejected. |
blockedDomains | string[] | Blocklist. |
allowedAssets | string[] | Defaults to [USDC mainnet] when unset. |
timeWindow | { window: 'minute'|'hour'|'day'; maxAmount: bigint } | Rolling-window cap. |
requireHumanApprovalOver | bigint | Amounts above this call onOverBudget for approval. |
onOverBudget | (attempt) => Promise<boolean> | Approval hook. Absent hook auto-denies. |
Browser extension — @otomat/wallet-extension
The Manifest V3 extension injects a frozen window.otomat bridge into pages. It pays 402s from an in-extension wallet, enforcing a spend policy in the service worker — the page never touches keys.
// In a page, after the extension has injected window.otomat:
const res = await window.otomat.fetch('https://api.otomat.fun/x/V1StGXR8_Z');
const data = await res.json();
// pay() returns metadata about the settlement:
const out = await window.otomat.pay('https://api.otomat.fun/x/V1StGXR8_Z');
// { response, paid, amount, asset, signature?, host }API: window.otomat.fetch(url, init?) auto-pays and returns the response; window.otomat.pay(url, init?) returns { response, paid, amount, asset, signature?, host }; window.otomat.version. The stored policy defaults to maxTotalUsdc: '50', maxPerCallUsdc: '1', requireApprovalOverUsdc: '0.5', allowedDomains: [].
Load unpacked
pnpm --filter @otomat/wallet-extension build
# Chrome: open chrome://extensions -> enable Developer mode
# -> "Load unpacked" -> select packages/wallet-extension/distOn-chain program — otomat_rail
Settlement is enforced by the otomat_rail Anchor program (v0.31.1). It exposes eight instructions, three account types, and seven events. Instruction data is prefixed with an 8-byte Anchor discriminator.
FwFC1nDo5r224uXDeTMc2X8FhXuiz9CqBF7tg3trEVhNInstructions
| Instruction | What it does |
|---|---|
open_channel(deposit: u64, expiry: i64, facilitator: Pubkey) | Escrows deposit into a channel vault and records the facilitator allowed to close it. |
close_channel(cumulative_amount: u64, nonce: u64, signature: [u8; 64]) | Facilitator settles the latest signed check: pays the payee the cumulative, refunds the rest, closes the channel. |
dispute_channel(counter_cumulative: u64, counter_nonce: u64, counter_signature: [u8; 64]) | Permissionless. Submits a higher-nonce check and opens the dispute window. |
resolve_dispute() | After the dispute window elapses, settles the channel at the recorded amounts. |
timeout_reclaim() | After expiry, the payer reclaims the un-spent deposit. Emits ChannelClosed and ChannelReclaimed. |
commit_receipt(amount: u64, resource_hash: [u8; 32], nonce: u64, scheme: u8) | Facilitator writes an on-chain receipt PDA for an exact-scheme settlement. |
open_facilitator_bond(amount: u64) | A facilitator operator stakes a bond to back its settlements. |
slash_facilitator(amount: u64, reason_hash: [u8; 32]) | Governance slashes a bonded facilitator for misbehavior. |
Accounts (PDAs)
| Account | Fields | PDA seeds |
|---|---|---|
Channel | payer, payee, mint, facilitator, deposit, cumulative_paid, nonce, expiry, status, dispute_window_end, bump, escrow_bump | seeds = [b"channel", payer, payee, mint] |
Receipt | payer, payee, amount, mint, resource_hash, nonce, timestamp, scheme | seeds = [b"receipt", payer, resource_hash, nonce_le] |
FacilitatorBond | operator, bond_amount, bond_mint, slashed, active, last_settle_ts | seeds = [b"facilitator_bond", operator] |
Events
| Event | Fields |
|---|---|
ChannelOpened | channel, payer, payee, deposit |
ChannelClosed | channel, cumulative_paid, refund, nonce |
ChannelDisputed | channel, counter_cumulative, counter_nonce |
ChannelReclaimed | channel, payer, mint, amount, timestamp |
ReceiptCommitted | payer, payee, amount, resource_hash, scheme |
FacilitatorBonded | operator, amount |
FacilitatorSlashed | operator, amount, reason_hash |
Errors
Exact-scheme rejection reasons
On verify or settle, a rejected payment carries one of these reason strings (surfaced as error in the re-issued 402, or invalidReason / errorReason from the facilitator).
| Reason | Condition |
|---|---|
invalid_exact_svm_payload_transaction | Payload has no valid transaction, or the bytes fail to deserialize as a VersionedTransaction. Also the default settle fallback. |
invalid_exact_svm_payload_transaction_address_table_lookup_forbidden | The message uses address-table lookups. Not allowed. |
invalid_exact_svm_payload_transaction_instructions_length | Fewer than 3 or more than 6 instructions, or one of the first three is missing. |
invalid_exact_svm_payload_transaction_compute_budget | Instruction [0] is not SetComputeUnitLimit, or [1] is not SetComputeUnitPrice. |
invalid_exact_svm_payload_transaction_compute_unit_limit_too_high | Compute unit limit exceeds 40000. |
invalid_exact_svm_payload_transaction_compute_unit_price_too_high | Compute unit price exceeds 5 micro-lamports. |
invalid_exact_svm_payload_transaction_transfer_instruction_invalid | Instruction [2] is not a valid SPL / Token-2022 TransferChecked (bad program, data, or accounts). |
invalid_exact_svm_payload_transaction_asset_mismatch | The transferred mint does not equal the required asset. |
invalid_exact_svm_payload_transaction_transfer_amount_mismatch | The transferred amount does not equal the required amount. |
invalid_exact_svm_payload_transaction_source_ata_mismatch | The source is not the payer associated token account for the mint. |
invalid_exact_svm_payload_transaction_destination_ata_mismatch | payTo is invalid, or the destination is not the payTo associated token account. |
invalid_exact_svm_payload_transaction_unexpected_instruction | An instruction at index 3+ targets a program outside the whitelist (Memo, Lighthouse, ComputeBudget). |
invalid_exact_svm_requirements_extra | The requirement extra field carries no valid feePayer. |
invalid_exact_svm_payload_transaction_fee_payer_mismatch | staticAccountKeys[0] is not the facilitator fee payer, or the payer equals the fee payer. |
invalid_exact_svm_payload_transaction_fee_payer_in_instruction | An instruction references account index 0 (the fee payer). |
invalid_exact_svm_payload_transaction_missing_payer_signature | The payer is not a required signer, its signature is missing or zero, or Ed25519 verification fails. |
invalid_exact_svm_payload_transaction_fee_payer_already_signed | signatures[0] (the fee-payer slot) is already filled; it must be left empty. |
invalid_exact_svm_payload_transaction_blockhash_expired | The recent blockhash is no longer valid. |
blockhash_check_failed | The RPC call to validate the blockhash threw. |
Settlement-only reasons
| Reason | Condition |
|---|---|
facilitator_signer_unavailable | The facilitator has no configured co-signer. |
payload_already_settled | Replay protection: this payer signature was already settled (120s TTL). |
insufficient_funds | On-chain settlement failed because the payer lacked funds. |
settle_failed | Generic settlement failure (re-deserialize, missing signature, or confirmation error). |
HTTP API errors
| Status | error | When |
|---|---|---|
| 400 | invalid_query | A GET query parameter failed validation (details carries the Zod field errors). |
| 400 | invalid_payTo | POST /api/endpoints: payTo is not a valid Solana pubkey. |
| 400 | invalid_message | POST /api/merchants: the signed message does not contain payTo. |
| 401 | invalid_signature | POST /api/merchants: the Ed25519 wallet signature did not verify. |
| 404 | not_found | No receipt / merchant / endpoint matched. |
| 404 | endpoint_not_found | GET /x/:id: the endpoint id is unknown or inactive. |
| 402 | (challenge) | GET /x/:id: payment required. Body is the PaymentRequirements JSON. |
| 502 | facilitator_unavailable | GET /x/:id: the facilitator could not be reached to verify/settle. |
| 502 | upstream_unreachable | GET /x/:id: payment settled but the origin URL did not respond. |
| 500 | internal_error | Unhandled server error. |
On-chain error codes
The program returns Anchor errors such as InvalidSignature, NonceNotIncreasing, CumulativeExceedsDeposit, CumulativeRegression, ChannelNotExpired, InvalidChannelStatus, FacilitatorMismatch, GovernanceMismatch, BondInsufficient, DisputeWindowActive, InvalidScheme, ZeroAmount, and InvalidExpiry.