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.
npm install @ripar/sdkimport { 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.
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 | — | USD-denominated: "$0.01" or "0.01". The facilitator converts to the asset's base units. |
method | "GET" | "POST" | "POST" | |
input | JSON Schema object | — | Published to discovery. This is what lets a caller build a valid request unaided. |
timeout | number (ms) | 30000 | Between 1,000 and 300,000. Exceeding it refunds the caller. |
listed | boolean | true | false keeps the endpoint payable but out of the index. |
tags | string[] | [] | |
handler | (ctx) => R | Promise<R> | — | Your code. Runs only after payment has settled. |
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 | Written to the execution record for this request. |
payment | { txId?, payer?, amount, asset } | Present once payment has been verified and settled. |
A handler that throws returns 5xx, and a 5xx refunds the caller. Never catch a failure
and return 200 with an error object in the body — the caller has then paid for
nothing and has 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 into something a marketplace can list, rank and route to.
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[] | [] | Used for matching, not for ranking. |
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. |
bidsOn | string[] | [] | Orchestrator job tags this agent bids on. |
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, so a test can
close it.
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 you never fund an ALGO balance. |
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. |
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. |
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",
"network": "mainnet",
"payTo": "ADDR…K7QX",
"endpoints": [
{
"name": "summarize",
"url": "https://api.example.com/summarize",
"method": "POST",
"price": "$0.01",
"input": { "type": "object", "required": ["text"] }
}
]
}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. |
fetchImpl | typeof fetch | globalThis.fetch | Swap in an instrumented fetch for tests. |
The client reads the quote first and throws price_above_max rather than paying it.
Without it, a wrapped fetch settles whatever it is quoted — which is exactly the
runaway you are trying to prevent.
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; // { amount: "$0.01", asset: "USDC", payTo: "ADDR…", nonce: "…" }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.
call()
Settles and returns CallResult<T>:
type CallResult<T> = {
data: T;
payment?: { txId?: string; amount?: string; asset?: string };
status: number;
};Throws no_signer if the client was constructed without a key, price_above_max if
the quote exceeds maxPrice, and call_failed (carrying status and the response
body in detail) for any non-2xx.
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) });