Payments & settlement
The path of one payment
- The caller requests your endpoint with no payment.
- The x402 middleware answers
402with a quote in thePAYMENT-REQUIREDheader:accepts[]carrying amount, asset, network,payTo, and a timeout. - The caller decodes it, checks it against its own caps, signs a USDC transfer for
exactly that amount, and retries with
PAYMENT-SIGNATURE. - The facilitator verifies the payment before your handler runs.
- Your handler runs. Its response is buffered.
- If it returned a success status, the facilitator settles and the receipt comes back in
PAYMENT-RESPONSE.
Settlement is direct: caller → the payTo address. Nothing in the middle takes custody.
PAYMENT-REQUIRED and PAYMENT-RESPONSE carry base64-encoded JSON. JSON.parse on
the raw value throws, and the body of a 402 is usually {} — so code that reads the
body as the quote gets nothing at all. PAYMENT-SIGNATURE is the x402 v2 name for the
request header; X-PAYMENT is the v1 spelling and is still accepted.
Amounts are base units, not dollars
An accepts entry that names an asset states its amount in that asset's atomic
units. 10000 of a 6-decimal USDC is $0.01, not $10,000. Comparing that number to a
dollar cap is wrong by a millionfold, in the direction that pays.
The SDK's priceOf() does the conversion and returns null for an asset whose decimals
it does not know, rather than assuming six:
import { priceOf } from "@ripar/sdk";
const q = await client.quote(url);
const usd = priceOf(q.requirements); // 0.01 | nullA null there is a quote a cap cannot check — RiparClient treats it as a refusal, not
as a zero.
What you get paid in
USDC as a native Algorand ASA: asset 31566704 on MainNet, 10458941 on TestNet. Not a
bridged representation and not a wrapped token — the same USDC Circle issues. The wrong
id per network is a payment that silently never settles, which is why the SDK exports the
constants rather than expecting you to type them.
Failure cases, and who eats them
The rule is simple: you are paid when work is delivered. If it is not delivered, nothing settles — the payment was signed but never captured, so there is no refund to wait for.
| What happened | Payment | Notes |
|---|---|---|
Handler returned 2xx | Settled | Normal path |
Handler returned 5xx or threw | Never settled | The caller is not charged for your outage |
Handler exceeded timeout | Never settled | 504 handler_timeout, configurable per endpoint |
| Failed input validation | Never settled | 400 before the payment middleware is reached |
| Rate limited | Never settled | 429 before the payment middleware is reached |
| Payment did not verify | Rejected | 402 again with the quote |
Idempotency
A caller that retries after a network blip must not pay twice for one unit of work. It is an HTTP header, not a body field, and it is opt-in on the server:
await serve(agent, { idempotency: { windowMs: 10 * 60_000 } });ripar call https://agent.example.com/summarize \
--body '{"text":"…"}' --idempotency-key job-8f21c-attemptawait ripar.call(url, body, { headers: { "Idempotency-Key": "job-8f21c-attempt" } });The first call executes and charges. A retry with the same key and the same body inside
the window returns the stored response with Idempotency-Replayed: true and does not
charge again. A retry that arrives while the first is still running gets 409 idempotency_in_progress; the same key with a different body gets 409 idempotency_key_reuse.
Two replicas keep two stores, and a restart empties both. And the guarantee is exactly
"a completed 2xx is replayed, not re-run" — a request that settled and then lost its
socket before writing a response releases its claim, and the retry settles again.
Reconciliation
Your own process keeps a capped ring buffer of recent calls:
curl -s https://your-host/_ripar/runs | jq '.runs[] | {endpoint, status, ms, txId}'That is 100 entries by default and it is gone on restart, so it is an operational view
rather than an accounting record. For accounting, read the chain — every settled call is
an Algorand transaction against your payTo address. The MCP
server's ripar_settlements pulls those from the indexer and marks each
one against what the ReputationRegistry has counted.