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.

npm install @ripar/sdk
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.

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.
pricestringUSD-denominated: "$0.01" or "0.01". The facilitator converts to the asset's base units.
method"GET" | "POST""POST"
inputJSON Schema objectPublished to discovery. This is what lets a caller build a valid request unaided.
timeoutnumber (ms)30000Between 1,000 and 300,000. Exceeding it refunds the caller.
listedbooleantruefalse keeps the endpoint payable but out of the index.
tagsstring[][]
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 }) => { … }
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?) => voidWritten to the execution record for this request.
payment{ txId?, payer?, amount, asset }Present once payment has been verified and settled.
Throwing is how a refund happens

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.

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 into something a marketplace can list, rank and route to.

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[][]Used for matching, not for ranking.
payTostringAlgorand address that receives settlement. 58 base32 characters.
network"mainnet" | "testnet""mainnet"
endpointsEndpointDef[]At least one. Names must be unique — they become URLs.
bidsOnstring[][]Orchestrator job tags this agent bids on.
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, so a test can close it.

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 you never fund an ALGO balance.
network"mainnet" | "testnet"The agent's network
payTostringThe agent's payToOverrides per deployment — useful for a staging address.
basePathstring""Mount prefix for every route.
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.
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",
  "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.

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.
fetchImpltypeof fetchglobalThis.fetchSwap in an instrumented fetch for tests.
maxPrice is checked before anything is signed

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 | 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) });