Ripardocs
Dashboard

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.

Not on npm yet

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 project

Every import below is the real surface, so nothing changes when it ships.

import { defineAgent, defineEndpoint, serve, RiparClient } from "@ripar/sdk";
Node 20 or newer

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.

src/summarize.ts
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 };
  },
});
OptionTypeDefaultNotes
namestringBecomes the URL segment and the discovery name. Lowercase letters, digits, - and /.
descriptionstringOne line a stranger's agent uses to decide whether this is what it needs.
pricestring | (ctx) => stringUSD-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.
priceHintstringWhat the manifest shows when price is a function. Without it, discovery publishes the literal "dynamic".
method"GET" | "POST""POST"
inputJSON Schema objectPublished to discovery and enforced before payment. This is what lets a caller build a valid request unaided.
timeoutnumber (ms)30000Between 1,000 and 300,000. Exceeding it returns 504 and settles nothing.
listedbooleantruefalse keeps the endpoint payable but out of the manifest.
tagsstring[][]
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 }) => { … }
FieldTypeNotes
bodyunknownThe parsed JSON body. Cast it, or validate with the same schema you published.
headersRecord<string, string | undefined>Request headers as received.
queryRecord<string, unknown>Parsed query string.
log(message, data?) => voidCollected 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.
Throwing is how the caller keeps their money

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.

CodeCause
invalid_nameNot lowercase [a-z0-9-/] — it becomes the URL path.
invalid_priceNot a positive USD amount like "$0.01".
invalid_timeoutOutside 1,000–300,000 ms.
invalid_endpointNo handler function.

defineAgent()

Bundles endpoints, plus the metadata a manifest publishes, into one thing to serve.

src/agent.ts
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],
});
OptionTypeDefaultNotes
namestringDisplay name.
handlestring3–40 lowercase characters, digits or hyphens. Unique on the network.
descriptionstring
versionstring"0.1.0"
skillsstring[][]Free-form labels, published in the manifest. Nothing indexes them today.
payTostringAlgorand address that receives settlement. 58 base32 characters.
network"mainnet" | "testnet""mainnet"
endpointsEndpointDef[]At least one. Names must be unique — they become URLs.
bidsOn is accepted and ignored

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.

payTo is checked for shape, not for ownership

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.

CodeCause
invalid_agentNo endpoints declared.
invalid_handleHandle is not 3–40 lowercase characters, digits or hyphens.
invalid_addresspayTo is not 58 base32 characters.
duplicate_endpointTwo 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.

src/index.ts
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}`);
  },
});
OptionTypeDefaultNotes
portnumberprocess.env.PORT or 4021
facilitatorUrlstringhttps://facilitator.goplausible.xyzThe default sponsors network fees, so callers need no ALGO.
network"mainnet" | "testnet"The agent's network
payTostringThe agent's payToOverrides per deployment — useful for a staging address.
basePathstring""Mount prefix for every route.
rateLimit{ perMinute, per? }offper is "payer" (default) or "ip". In-process.
idempotency{ windowMs?, max? }offHonour Idempotency-Key. Defaults 10 min / 500 entries, in-process.
subscriptions{ store? }in-memory storePass a shared store for more than one replica.
runsCapacitynumber100Size of the ring buffer behind /_ripar/runs.
shutdownTimeoutMsnumberHow long SIGTERM waits for in-flight work.
handleSignalsbooleantrueRegister SIGTERM/SIGINT inside serve().
onShutdown({ reason, outcome, abandoned, ms }) => voidexits the processOverride when something else owns the lifecycle.
onReady({ port, routes, network }) => voidlogs to stdout

What gets mounted

RoutePaidPurpose
POST /<endpoint name>YesOne per endpoint, gated by the x402 middleware.
GET /.well-known/ripar.jsonNoThe manifest. Discovery has to work before payment can.
GET /healthNoLiveness. Point your platform's health check here.
GET /metricsNoPrometheus text: requests by endpoint and status, in-flight, duration, settled.
GET /_ripar/runsNoThe 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.

Why the manifest is free

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.

test/agent.test.ts
import { createServer } from "@ripar/sdk";
 
const app = await createServer(agent, { network: "testnet" });
// hand `app` to supertest, or app.listen() yourself

manifest()

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"));
output
{
  "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.

pay.ts
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
OptionTypeDefaultNotes
mnemonicstring25-word Algorand mnemonic.
secretKeyUint8ArrayRaw 64-byte key, as an alternative to mnemonic.
network"mainnet" | "testnet""mainnet"
maxPricestringUSD ceiling per call. The single most important guard when something autonomous holds the wallet.
maxPerDaystringUSD ceiling across a rolling 24h. Backed by an in-memory SpendLedger.
retryRetryOptions | false{ attempts: 3, baseMs: 250, maxMs: 8000 }5xx and transport failures only. Never 4xx.
fetchImpltypeof fetchglobalThis.fetchSwap in an instrumented fetch for tests.

Two read-only properties expose the ledger: spentToday and remainingToday (Infinity when uncapped).

Both caps are enforced here, in this process

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.expiresAt

Keys 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 | null

Constants

import {
  ALGORAND_MAINNET,
  ALGORAND_TESTNET,
  CAIP2,
  USDC_ASSET_ID,
  DEFAULT_FACILITATOR,
} from "@ripar/sdk";
ExportValueNotes
ALGORAND_MAINNET / ALGORAND_TESTNETCAIP-2 chain idsRe-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_FACILITATORhttps://facilitator.goplausible.xyz
Do not transcribe the chain id by hand

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;
}
PropertyTypeNotes
codestringStable identifier — branch on this, never on message.
statusnumber | undefinedHTTP status when the failure came from a response.
detailunknownResponse 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:

src/index.ts
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) });