Build an agent
Define the endpoint
An endpoint is a handler plus a price. Nothing else is required:
import { defineEndpoint } from "@ripar/sdk";
export const summarize = defineEndpoint({
name: "summarize",
description: "Returns a short summary of any text payload.",
price: "$0.01",
timeout: 30_000,
input: {
type: "object",
properties: { text: { type: "string", minLength: 1 } },
required: ["text"],
additionalProperties: false,
},
// The handler takes one argument — log is a field on it, not a second param.
async handler({ body, log }) {
const { text } = body as { text: string };
log("summarizing", { chars: text.length });
return { summary: await summarise(text) };
},
});The input schema is enforced before your handler runs — and published in the
manifest so callers can construct valid requests unaided.
Bundle and serve
import { defineAgent, serve } from "@ripar/sdk";
await serve(
defineAgent({
name: "Text Tools",
handle: "text-tools",
description: "Small, cheap text utilities priced per call.",
payTo: process.env.RIPAR_PAY_TO!, // settlement lands here directly
network: (process.env.RIPAR_NETWORK as "mainnet" | "testnet") ?? "testnet",
endpoints: [summarize],
}),
{ port: Number(process.env.PORT ?? 4021) }
);There is no payment code in that file, and that is the point. serve() puts x402 in
front, so an unpaid request never reaches your handler — and the process holds no signing
key at all.
Three things that happen before payment
await serve(agent, {
rateLimit: { perMinute: 60, per: "payer" }, // 429 + Retry-After
idempotency: { windowMs: 10 * 60_000 }, // honour Idempotency-Key
});The order is rate limit → idempotency → input validation → payment → handler, and the
position is the feature. A request rejected by any of them has not been charged,
because the payment middleware was never reached.
- Input validation — the body is checked against the same
inputschema the manifest publishes, and a failure is a400naming the field, with the schema echoed back. A request with no body and no payment is treated as a price probe and passes through, because a caller needs the 402 in order to learn the schema in the first place. - Rate limiting — keyed on the Algorand address inside the payment header, so a caller
cannot get a fresh budget by changing IP. Off unless you pass
rateLimit, in-process (two replicas allow two windows), and inper: "payer"mode it does not limit unpaid traffic at all. The header is not signature-checked before the limiter reads it, so the payer identity is a claim rather than proof — see the known-gap note in the SDK'ssrc/identity.ts. - Idempotency — a caller who did not receive an answer retries with the same
Idempotency-Keyand gets the stored response instead of paying again. Concurrent retries get409 idempotency_in_progress; the same key with a different body gets409 idempotency_key_reuse.
Exactly: "a completed 2xx is replayed, not re-run". Not: "a dropped connection can
never be charged twice". A request that settled and then lost its socket before writing
a response releases its claim, and the retry does reach the payment middleware and
settle again. The store is also in-process — two instances keep two stores.
Pricing models
An endpoint charges per call or sells a window. price accepts either a fixed amount or a
function:
price: "$0.01" // flat, per request
price: ({ body }) => … // you quote each request
subscription: { price: "$5.00", period: "30d" } // one payment buys a windowprice and subscription are mutually exclusive — TypeScript rejects an endpoint that
sets both, because the 402 can only quote one number.
With a function, quote from anything on the request. It runs before the work:
defineEndpoint({
name: "complete",
price: ({ body }) => `$${(0.002 + 0.0004 * Math.ceil(body.maxTokens / 100)).toFixed(4)}`,
priceHint: "$0.0024–$0.0100 (by maxTokens)", // what the manifest shows
handler,
});It has to be cheap (every unpaid probe pays its cost) and deterministic for a given body
(the caller quotes, signs, and sends again — a price that moved in between rejects a
payment they built correctly). A price function cannot be serialised, so the manifest
publishes your priceHint, or the literal string "dynamic" if you did not write one.
The amount your price() returns is what goes in the 402. You cannot charge more than
you quoted after the fact — if the work turns out larger, that is your loss, so build
the margin into the quote.
Subscriptions
x402 settles one request at a time and nothing can pull from a caller's wallet later, so a subscription here is a single settlement that buys a window:
defineEndpoint({
name: "feed",
subscription: { price: "$5.00", period: "30d" },
handler: async () => ({ items: await latest() }),
})The first unpaid call gets a 402 quoting $5.00. Once it settles, the response carries
x-ripar-subscription: rsk_…, and every call presenting that key runs free until it
expires — the request never reaches the facilitator at all.
const sub = await client.subscribe("https://agent.example/feed")
await client.call("https://agent.example/feed") // free, until sub.expiresAtA key is scoped to the endpoint that sold it, so the cheapest window on your agent cannot open the dearest one. Nothing renews on its own: when the window closes the endpoint quotes again and the caller decides.
Keys live in memory unless you pass one. Behind two replicas, a key minted on A is
unknown to B and the caller is asked to pay twice. Pass a shared store —
serve(agent, { subscriptions: { store } }) — for anything running more than one
instance. The interface is get/put/sweep.
Failure is how the caller keeps their money
@x402/express buffers the response and only settles after the handler returns a success
status. A 4xx or 5xx cancels settlement, so a failed call is never charged rather
than charged and refunded.
async handler({ body }) {
const out = await work(body);
if (!out) throw new Error("upstream returned nothing"); // 500, nothing settles
return out;
}Catching a failure and returning 200 with an error object in the body settles the
payment. The caller has then paid for nothing and has no way to tell.
A handler that exceeds timeout (default 30s, max 300s) is abandoned with
handler_timeout and a 504, which likewise never settles.
Secrets
Ordinary environment variables — there is no Ripar-managed secret store, and no
ripar env command. Set them however your platform sets environment variables, and keep
.env out of git:
RIPAR_PAY_TO=YOUR_ALGORAND_ADDRESS
RIPAR_NETWORK=testnet
OPENAI_API_KEY=sk-…Your handler reads them from process.env like any other Node process.
Running it
Locally:
ripar devIn production, it is a plain Node server — node dist/index.js, or node agent.ts on
Node 22.6+. There is no ripar deploy; see Deploy anywhere
for Railway, Render, Fly.io, Heroku and Docker.
Operating routes
Every agent mounts four unpaid routes alongside its endpoints:
| Route | For |
|---|---|
GET /health | Platform probes. A paid one would read a healthy agent as down. |
GET /.well-known/ripar.json | Discovery. It has to work before payment can. |
GET /metrics | Prometheus: requests by endpoint and status, in-flight, duration, settled. |
GET /_ripar/runs | The last calls: id, endpoint, status, ms, txId. No request bodies. |
/_ripar/runs is a capped in-memory ring buffer (100 by default, runsCapacity to
change it), so it is an operational view and not an accounting record.
Shutting down
serve() installs SIGTERM/SIGINT handlers by default. On signal the agent answers
503 shutting_down with Retry-After to new requests and drains the in-flight ones —
a caller told to come back has lost nothing, where a dropped paid call has.
await serve(agent, {
shutdownTimeoutMs: 10_000,
handleSignals: false, // when something else owns the lifecycle
onShutdown: ({ outcome, abandoned }) => log.info({ outcome, abandoned }),
});