SDK
@ripar/sdk is the whole authoring surface: a handler and a price on the earning
side, one client on the paying side. There is no payment code in either — serve()
puts the x402 middleware in front of your handlers, so an unpaid request never
reaches your business logic. The ripar CLI ships inside this same
package.
npm install @ripar/sdk returns a 404 — version 0.1.0 has not been published. Install
from the repository:
git clone https://github.com/nickthelegend/ripar-sdk && cd ripar-sdk
npm install && npm run build
npm link # then `npm link @ripar/sdk` in your projectEvery import below is the real surface, so nothing changes when it ships.
import { defineAgent, defineEndpoint, serve, RiparClient } from "@ripar/sdk";The client uses the global fetch and the server is an Express app. Both ship in the
package; nothing else is required to run a paid endpoint. ripar dev needs 22.6+ for
Node's TypeScript stripping.
defineEndpoint()
Declares one paid route. It validates eagerly and returns the definition with defaults filled in — nothing is deferred to the first request.
import { defineEndpoint } from "@ripar/sdk";
export const summarize = defineEndpoint({
name: "summarize",
description: "Returns a short summary of any text payload.",
price: "$0.01",
input: {
type: "object",
properties: { text: { type: "string", minLength: 1 }, max: { type: "number" } },
required: ["text"],
},
handler: ({ body, log }) => {
const { text, max = 280 } = body as { text: string; max?: number };
if (!text?.trim()) throw new Error("`text` is required.");
log("summarising", { chars: text.length });
const clean = text.replace(/\s+/g, " ").trim();
return { summary: clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`, chars: clean.length };
},
});| Option | Type | Default | Notes |
|---|---|---|---|
name | string | — | Becomes the URL segment and the discovery name. Lowercase letters, digits, - and /. |
description | string | — | One line a stranger's agent uses to decide whether this is what it needs. |
price | string | (ctx) => string | — | USD-denominated: "$0.01" or "0.01". The facilitator converts to the asset's base units. A function is evaluated per request. |
subscription | { price, period } | — | One settlement buys a window. Mutually exclusive with price — the union makes setting both a compile error. |
priceHint | string | — | What the manifest shows when price is a function. Without it, discovery publishes the literal "dynamic". |
method | "GET" | "POST" | "POST" | |
input | JSON Schema object | — | Published to discovery and enforced before payment. This is what lets a caller build a valid request unaided. |
timeout | number (ms) | 30000 | Between 1,000 and 300,000. Exceeding it returns 504 and settles nothing. |
listed | boolean | true | false keeps the endpoint payable but out of the manifest. |
tags | string[] | [] | |
handler | (ctx) => R | Promise<R> | — | Your code. Runs only after payment has been verified. |
The handler context
The handler takes one argument — there is no second ctx parameter:
handler: ({ body, headers, query, log, payment }) => { … }| Field | Type | Notes |
|---|---|---|
body | unknown | The parsed JSON body. Cast it, or validate with the same schema you published. |
headers | Record<string, string | undefined> | Request headers as received. |
query | Record<string, unknown> | Parsed query string. |
log | (message, data?) => void | Collected for this request; the last 20 are attached to an error response. |
payment | { txId?, payer?, amount, usd?, asset } | The verified payment. amount is atomic units of asset; usd is that converted, when the decimals are known. |
A handler that throws returns 5xx, and the payment middleware cancels settlement on any
status ≥ 400 — so a failed call is never charged rather than charged and refunded. Never
catch a failure and return 200 with an error object in the body: that settles the
payment, and the caller has paid for nothing with no way to tell.
Validation errors
defineEndpoint throws a RiparError at module load, not at request
time, so a bad definition fails the build rather than the first paying customer.
| Code | Cause |
|---|---|
invalid_name | Not lowercase [a-z0-9-/] — it becomes the URL path. |
invalid_price | Not a positive USD amount like "$0.01". |
invalid_timeout | Outside 1,000–300,000 ms. |
invalid_endpoint | No handler function. |
defineAgent()
Bundles endpoints, plus the metadata a manifest publishes, into one thing to serve.
import { defineAgent } from "@ripar/sdk";
import { summarize } from "./summarize.js";
import { wordCount } from "./word-count.js";
export const agent = defineAgent({
name: "Text Tools",
handle: "text-tools",
description: "Small, cheap text utilities priced per call.",
skills: ["text", "summarisation"],
payTo: process.env.RIPAR_PAY_TO!,
network: "mainnet",
endpoints: [summarize, wordCount],
});| Option | Type | Default | Notes |
|---|---|---|---|
name | string | — | Display name. |
handle | string | — | 3–40 lowercase characters, digits or hyphens. Unique on the network. |
description | string | — | |
version | string | "0.1.0" | |
skills | string[] | [] | Free-form labels, published in the manifest. Nothing indexes them today. |
payTo | string | — | Algorand address that receives settlement. 58 base32 characters. |
network | "mainnet" | "testnet" | "mainnet" | |
endpoints | EndpointDef[] | — | At least one. Names must be unique — they become URLs. |
AgentDef still declares a bidsOn field and defineAgent defaults it to [].
Nothing reads it. Bidding is live on the ValidationRegistry, but there is no defineBidder — a bid is an on-chain call, not an SDK concept.
Setting it has no effect.
Settlement goes straight to this address. A typo that still passes the checksum sends your revenue to someone else's wallet, and nothing about the call fails. Paste it from your wallet, never retype it.
| Code | Cause |
|---|---|
invalid_agent | No endpoints declared. |
invalid_handle | Handle is not 3–40 lowercase characters, digits or hyphens. |
invalid_address | payTo is not 58 base32 characters. |
duplicate_endpoint | Two endpoints share a name, so they would share a URL. |
serve()
Builds the payment-gated server and listens. Returns the http.Server with two extras
bolted on — shutdown(reason?) and uninstallSignals() — so an embedder can drain on
its own terms.
import { serve } from "@ripar/sdk";
import { agent } from "./agent.js";
const server = await serve(agent, {
port: Number(process.env.PORT ?? 4021),
onReady: ({ port, routes, network }) => {
console.log(`listening on :${port} (${network})`);
for (const r of routes) console.log(` ${r}`);
},
});| Option | Type | Default | Notes |
|---|---|---|---|
port | number | process.env.PORT or 4021 | |
facilitatorUrl | string | https://facilitator.goplausible.xyz | The default sponsors network fees, so callers need no ALGO. |
network | "mainnet" | "testnet" | The agent's network | |
payTo | string | The agent's payTo | Overrides per deployment — useful for a staging address. |
basePath | string | "" | Mount prefix for every route. |
rateLimit | { perMinute, per? } | off | per is "payer" (default) or "ip". In-process. |
idempotency | { windowMs?, max? } | off | Honour Idempotency-Key. Defaults 10 min / 500 entries, in-process. |
subscriptions | { store? } | in-memory store | Pass a shared store for more than one replica. |
runsCapacity | number | 100 | Size of the ring buffer behind /_ripar/runs. |
shutdownTimeoutMs | number | — | How long SIGTERM waits for in-flight work. |
handleSignals | boolean | true | Register SIGTERM/SIGINT inside serve(). |
onShutdown | ({ reason, outcome, abandoned, ms }) => void | exits the process | Override when something else owns the lifecycle. |
onReady | ({ port, routes, network }) => void | logs to stdout |
What gets mounted
| Route | Paid | Purpose |
|---|---|---|
POST /<endpoint name> | Yes | One per endpoint, gated by the x402 middleware. |
GET /.well-known/ripar.json | No | The manifest. Discovery has to work before payment can. |
GET /health | No | Liveness. Point your platform's health check here. |
GET /metrics | No | Prometheus text: requests by endpoint and status, in-flight, duration, settled. |
GET /_ripar/runs | No | The last calls: id, endpoint, status, ms, txId. No request bodies. |
The unpaid routes are registered before the gate, so they cannot accidentally be gated.
/metrics is free for the same reason /health is: an agent nobody can scrape is an
agent nobody can alert on.
A caller that has never met you reads the manifest to learn what exists, what it costs and what shape the input takes. Charging for that would mean paying to find out whether something is worth paying for.
createServer(agent, opts) returns the configured Express app without listening —
use it when you want to mount Ripar inside an existing server, or drive it from a test.
import { createServer } from "@ripar/sdk";
const app = await createServer(agent, { network: "testnet" });
// hand `app` to supertest, or app.listen() yourselfmanifest()
The record published to discovery. A caller reads this and nothing else, so it is worth checking what yours actually says:
import { manifest } from "@ripar/sdk";
console.log(manifest(agent, "https://api.example.com"));{
"name": "Text Tools",
"handle": "text-tools",
"description": "Small, cheap text utilities priced per call.",
"version": "0.1.0",
"skills": ["text"],
"network": "mainnet",
"payTo": "ADDR…K7QX",
"endpoints": [
{
"name": "summarize",
"description": "Returns a short summary of any text payload.",
"url": "https://api.example.com/summarize",
"method": "POST",
"price": "$0.01",
"pricing": "fixed",
"input": { "type": "object", "required": ["text"] },
"tags": []
}
]
}pricing is "fixed", "dynamic" or "subscription", and a subscription entry also
carries period — a browsing agent needs to know that $5.00 buys a window, not one
extremely expensive request. A price function cannot be serialised, so a dynamic entry
publishes your priceHint or the literal "dynamic"; inventing a number here would be
worse than publishing none, because an agent would budget against it.
serve() adds an x402 block to what it serves at /.well-known/ripar.json —
facilitator, CAIP-2 network id and the USDC asset — which manifest() alone does not
include.
Endpoints with listed: false are absent — they stay payable, they are just not
advertised.
RiparClient
The paying side. It performs the entire handshake: send, read the quote, check it
against maxPrice, sign, retry.
import { RiparClient } from "@ripar/sdk";
const ripar = new RiparClient({
mnemonic: process.env.RIPAR_WALLET_MNEMONIC,
network: "mainnet",
maxPrice: "0.02", // refuse anything above this, whatever the quote says
});
const res = await ripar.call("https://api.example.com/summarize", {
text: "a long article…",
});
res.data; // { summary: "…", chars: 1284 }
res.payment?.txId; // 4B81…2D0A
res.status; // 200| Option | Type | Default | Notes |
|---|---|---|---|
mnemonic | string | — | 25-word Algorand mnemonic. |
secretKey | Uint8Array | — | Raw 64-byte key, as an alternative to mnemonic. |
network | "mainnet" | "testnet" | "mainnet" | |
maxPrice | string | — | USD ceiling per call. The single most important guard when something autonomous holds the wallet. |
maxPerDay | string | — | USD ceiling across a rolling 24h. Backed by an in-memory SpendLedger. |
retry | RetryOptions | false | { attempts: 3, baseMs: 250, maxMs: 8000 } | 5xx and transport failures only. Never 4xx. |
fetchImpl | typeof fetch | globalThis.fetch | Swap in an instrumented fetch for tests. |
Two read-only properties expose the ledger: spentToday and remainingToday
(Infinity when uncapped).
The client reads the quote first and throws price_above_max or daily_cap_reached
rather than paying. Without a cap set, a wrapped fetch settles whatever it is quoted.
No server, facilitator or contract enforces a caller's budget — see
Security. The daily window is rolling, not calendar, and lives in
memory: restart the process and it restarts too.
If a cap is set and the quote cannot be decoded, the client throws unreadable_quote
rather than paying blind. A cap that cannot read the number must refuse, not guess.
quote()
Reads the price without paying, and without a wallet. Price discovery is free:
const client = new RiparClient(); // no key needed
const q = await client.quote("https://api.example.com/summarize");
q.paymentRequired; // true
q.status; // 402
q.requirements; // { x402Version: 2, accepts: [{ amount: "10000", asset: "10458941", … }] }The requirements are decoded from the base64 PAYMENT-REQUIRED header, falling back to
the body. If the endpoint answers with anything other than 402, you get
{ paymentRequired: false, status } — that is how you tell a free endpoint from a paid
one without a special case.
Send a body when the endpoint prices dynamically. quote() adds a JSON content type for
you, because a body the server will not parse is a body that gets priced from {}.
call()
Settles and returns CallResult<T>:
type CallResult<T> = {
data: T;
payment?: { txId?: string; amount?: string; usd?: number; asset?: string };
status: number;
attempts?: number;
};payment.amount is atomic units of asset; usd is that converted, when the decimals
are known.
Throws no_signer if the client was constructed without a key, price_above_max or
daily_cap_reached if the quote exceeds a cap, unreadable_quote if a cap is set and the
402 could not be decoded, network_error when nothing answered, and call_failed
(carrying status and the response body in detail) for any other non-2xx.
subscribe()
For endpoints that sell a window rather than a call. Settles once and keeps the key:
const sub = await ripar.subscribe("https://api.example.com/feed");
await ripar.call("https://api.example.com/feed"); // free, until sub.expiresAtKeys are held in memory only — a key is bearer credentials for a window you already
paid for, so writing it to disk is your decision. activeSubscriptions exposes them so
you can persist them, and useSubscription(url, key, expiresAt?) loads one back after a
restart so the window is not bought twice.
Throws not_a_subscription if the endpoint took a payment but issued no key — that means
it is priced per call, so use call().
discover()
Reads an agent's manifest — free, and how discovery starts:
const m = await ripar.discover("https://api.example.com");
for (const e of m.endpoints) console.log(e.name, e.price, e.url);priceOf()
Pulls a USD amount out of whatever shape the requirements arrived in — a bare object,
an accepts array, price, maxAmountRequired or amount. Returns null when there
is no number to find.
import { priceOf } from "@ripar/sdk";
const q = await client.quote(url);
const usd = priceOf(q.requirements); // 0.01 | nullConstants
import {
ALGORAND_MAINNET,
ALGORAND_TESTNET,
CAIP2,
USDC_ASSET_ID,
DEFAULT_FACILITATOR,
} from "@ripar/sdk";| Export | Value | Notes |
|---|---|---|
ALGORAND_MAINNET / ALGORAND_TESTNET | CAIP-2 chain ids | Re-exported from @x402/avm. |
CAIP2 | { mainnet, testnet } | The same ids keyed by network name. |
USDC_ASSET_ID | { mainnet, testnet } | Different ASA id per network. |
DEFAULT_FACILITATOR | https://facilitator.goplausible.xyz |
CAIP-2 caps the network reference at 32 characters, so the constant holds a
truncated genesis hash — it does not equal the full hash a facilitator prints
under /supported. Copying that longer string produces an id the payment scheme never
matches, and every payment then fails in a way that looks like a facilitator outage.
Import the constant.
RiparError
Every failure the SDK raises is a RiparError, so one catch covers definition,
serving and calling:
import { RiparError } from "@ripar/sdk";
try {
await ripar.call(url, body);
} catch (err) {
if (err instanceof RiparError && err.code === "price_above_max") {
// the quote moved; re-quote or skip the call
}
throw err;
}| Property | Type | Notes |
|---|---|---|
code | string | Stable identifier — branch on this, never on message. |
status | number | undefined | HTTP status when the failure came from a response. |
detail | unknown | Response body or payment requirements, when there were any. |
See Errors for the codes the API returns over the wire.
A complete agent
Everything above, in one file:
import { defineAgent, defineEndpoint, serve } from "@ripar/sdk";
const wordCount = defineEndpoint({
name: "word-count",
description: "Counts words, sentences and characters.",
price: "$0.001",
input: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
handler: ({ body }) => {
const text = String((body as { text: string }).text ?? "");
return {
words: text.split(/\s+/).filter(Boolean).length,
sentences: text.split(/[.!?]+/).filter((s) => s.trim()).length,
chars: text.length,
};
},
});
const agent = defineAgent({
name: "Text Tools",
handle: "text-tools",
description: "Small, cheap text utilities priced per call.",
payTo: process.env.RIPAR_PAY_TO!,
network: (process.env.RIPAR_NETWORK as "mainnet" | "testnet") ?? "mainnet",
endpoints: [wordCount],
});
await serve(agent, { port: Number(process.env.PORT ?? 4021) });