docs
From 402 to 200 in five minutes.
Quickstarts for both SDKs, every policy with an example, and the honest answers on keys and custody.
{ read the code, too }
Get started
Quickstart: sell an API
Paywall puts an x402 price on any route. The fastest path is the CLI, which detects Hono, Express, Next.js or an MCP server and wires the middleware for you.
npx @coinslot/cli initpackages land on npm with v0.1 — until then install from the repo
Or install it by hand and add three lines:
npm i @coinslot/paywallimport { paywall } from "@coinslot/paywall/hono"
app.use("/v1/*", paywall({ price: "$0.002", payTo: env.WALLET }))
// 402 → paid → 200. USDC lands in your wallet, not ours.payTo is the address that receives USDC. Start on network "base-sepolia" and make a test payment:
npx @coinslot/cli test http://localhost:3000/v1/factYou should see the 402, the payment, and a 200 with a PAYMENT-RESPONSE header. Switch the network to "base" or "solana" when you're ready for mainnet.
Get started
Quickstart: guard an agent
Guard wraps your x402 client and fetch. Give it the signer you already use and a few limits; it decides on every 402 before anything is signed.
npm i @coinslot/guardpackages land on npm with v0.1 — until then install from the repo
const guard = createGuard({ signer, daily: "$5" })
const research = guard.task("research", { budget: "$0.50" })
await research.fetch("https://api.example.com/v1/fact")A fuller setup, with a ledger, an allow-list and events:
import { createGuard, fileLedger } from "@coinslot/guard"
import { privateKeyToAccount } from "viem/accounts"
import type { Hex } from "viem"
// The signer stays on this machine. Guard never sends keys anywhere.
const signer = privateKeyToAccount(process.env.AGENT_KEY as Hex)
const guard = createGuard({
signer,
perCall: "$0.01",
daily: "$5",
allow: { domains: ["api.example.com", "*.weather.dev"] },
ledger: fileLedger(".coinslot/ledger.json"),
})
guard.on("blocked", (e) => console.warn("blocked:", e.rule, e.reason))
guard.on("paid", (e) => console.log("paid", e.amount, "left", e.budgetLeft))
const research = guard.task("research", { budget: "$0.50" })
const res = await research.fetch("https://api.example.com/v1/fact")
console.log(await res.json())Reference
Policies
Every 402 runs through these checks before anything is signed. If one fails, there is no payment, and a blocked event carries the rule id and a plain-language reason.
| Rule id | Option | Blocks when |
|---|---|---|
| kill_switch | killSwitch | Stop was pressed in the dashboard |
| budget | task(name, { budget }) | This payment would cross the task budget |
| per_call | perCall | The 402 asks for more than the per-call max |
| daily | daily | Today's spend would cross the daily cap |
| domain_list | allow / deny .domains | The domain is denied, or not on the allow-list |
| payto_list | allow / deny .payTo | The address is denied, or not on the allow-list |
| payto_pinned | pinPayTo (default true) | The seller's payTo changed after first approval |
| approval | onNewSeller | A new seller wasn't approved |
| not_paid_before | ledger | This request was already paid; the retry re-sends that payment |
budget
const research = guard.task("research", { budget: "$0.50" })
await research.fetch("https://api.example.com/v1/fact")
// the call that would cross $0.50 → blocked · budgetper_call
const guard = createGuard({ signer, perCall: "$0.01" })
// a 402 asking for $0.05 → blocked · per_calldaily
const guard = createGuard({ signer, daily: "$5" })
// one cap above every task, every call, all daydomain_list
Wildcards match subdomains. A deny entry always wins over an allow entry.
const guard = createGuard({
signer,
allow: { domains: ["api.example.com", "*.weather.dev"] },
deny: { domains: ["ads.example.org"] },
})payto_list
const guard = createGuard({
signer,
allow: { payTo: ["0xSellerYouTrust"] },
deny: { payTo: ["0xAddressYouDont"] },
})payto_pinned
On by default. The first approved payTo for a seller is stored; any later 402 from that seller with a different address is refused. Turn it off only if you know why.
const guard = createGuard({ signer, pinPayTo: true }) // the default
// approved once: api.example.com → 0x4b…7e
// later a 402 asks for 0x9a…c3 → blocked · payto_pinnedapproval
onNewSeller runs once per seller you've never paid. Resolve true to pay, false to block. A Telegram bot with an ok button is a common choice.
const guard = createGuard({
signer,
// first payment to a new seller waits for a human
onNewSeller: async (seller) => askOnTelegram(seller), // true = pay
})not_paid_before
The ledger records each payment before it's sent. On a retry, Guard re-sends the recorded payment instead of signing a new one, so a timeout can't turn into a second charge. The retry shows up as a replayed event.
import { createGuard, fileLedger } from "@coinslot/guard"
const guard = createGuard({
signer,
ledger: fileLedger(".coinslot/ledger.json"),
})
// paid → timeout → retry: the same payment is re-sent, never a new onekill_switch
const guard = createGuard({
signer,
killSwitch: { apiKey: process.env.COINSLOT_API_KEY },
})
// press Stop in the dashboard → next payment: blocked · kill_switchReference
Paywall options
The same options work for Hono, Express, Next.js (withPaywall) and MCP (paidTool).
paywall({
payTo: process.env.WALLET!,
network: "base", // "base" | "base-sepolia" | "solana" | "solana-devnet" | "eip155:8453"
facilitator: "cdp", // "cdp" | "payai" | "thirdweb" | { url: "https://..." }
routes: {
"GET /v1/fact": "$0.001",
"POST /v1/search": "$0.01",
},
rateLimit: { perPayer: 60, window: "1m" },
idempotency: true, // default
receipts: { apiKey: process.env.COINSLOT_API_KEY },
bazaar: { description: "Random facts, one per call" },
})| Option | Default | What it does |
|---|---|---|
| payTo | required | Address that receives USDC |
| network | base | base, base-sepolia, solana, solana-devnet, or a CAIP-2 id like eip155:8453 |
| price | none | One price for every matched route, e.g. "$0.002" |
| routes | none | Price per route: { "GET /v1/fact": "$0.001" } |
| facilitator | cdp | cdp, payai, thirdweb, or { url } for your own |
| rateLimit | off | { perPayer: 60, window: "1m" } per paying wallet |
| idempotency | true | A retry with the same payment returns the stored response, no second charge |
| receipts | off | { apiKey } sends settled payments to the hosted dashboard |
| bazaar | off | { description } lists the endpoint in the x402 Bazaar |
Next.js
// app/api/fact/route.ts
import { withPaywall } from "@coinslot/paywall/next"
export const GET = withPaywall(
{ price: "$0.001", payTo: process.env.WALLET! },
async (req) => Response.json({ fact: "Bananas are berries. Strawberries aren't." }),
)MCP tools
import { paidTool } from "@coinslot/paywall/mcp"
import { z } from "zod"
server.tool(
"search",
{ query: z.string() },
paidTool({ price: "$0.002", payTo: process.env.WALLET! }, async ({ query }) => ({
content: [{ type: "text", text: await search(query) }],
})),
)Reference
CLI
| Command | What it does |
|---|---|
| npx @coinslot/cli init | Detects Hono, Express, Next.js, MCP or an agent project and wires Paywall or Guard |
| npx @coinslot/cli test <url> | Makes one paid request on Base Sepolia with test USDC and prints every step |
npx @coinslot/cli test https://api.example.com/v1/factpackages land on npm with v0.1 — until then install from the repo
test always runs on testnet. It shows the 402, the policy checks, the payment, the 200 and the receipt, so you can check a paywall before real money is involved.
Guides
Facilitators
A facilitator verifies x402 payments and settles them on-chain. Coinslot doesn't run one and doesn't need to: pick one in config.
paywall({ price: "$0.002", payTo, facilitator: "cdp" }) // default
paywall({ price: "$0.002", payTo, facilitator: "payai" })
paywall({ price: "$0.002", payTo, facilitator: "thirdweb" })
paywall({ price: "$0.002", payTo, facilitator: { url: "https://facilitator.example.com" } })| Facilitator | id | Notes |
|---|---|---|
| Coinbase CDP | cdp | Default. Base, Solana and more. Free monthly allowance, then a per-transaction fee billed to the seller |
| PayAI | payai | Base and Solana. See PayAI docs for fees |
| thirdweb | thirdweb | EVM chains. See thirdweb docs for fees |
| Your own | { url } | Any x402 V2 facilitator |
Guides
Events & OpenTelemetry
Guard emits an event at every step of a paid request. Every event carries a traceId, so one payment can be followed from request to receipt.
| Event | When | Key fields |
|---|---|---|
| request | The agent calls a URL | task, url |
| payment_required | A 402 came back | amount, network, payTo |
| policy_check | Each rule ran | rule, ok, detail |
| approval_requested | A new seller needs your ok | seller, payTo |
| paid | A payment was signed and sent | amount, budgetLeft |
| settled | The seller answered after payment | status, txHash, explorerUrl |
| replayed | A retry was answered without a new charge | originalTraceId |
| blocked | A rule refused the payment | rule, reason |
| error | Something failed | message |
guard.on("payment_required", (e) => log(e.traceId, "402", e.amount, e.network, e.payTo))
guard.on("policy_check", (e) => log(e.traceId, e.rule, e.ok ? "ok" : "no", e.detail))
guard.on("paid", (e) => log(e.traceId, "paid", e.amount, "left", e.budgetLeft))
guard.on("settled", (e) => log(e.traceId, e.status, e.explorerUrl))
guard.on("replayed", (e) => log(e.traceId, "replayed from", e.originalTraceId))
guard.on("blocked", (e) => log(e.traceId, "blocked", e.rule, e.reason))OpenTelemetry
Pass telemetry: otel() and each payment becomes a span in your existing tracing, next to the agent run that made it.
import { createGuard, otel } from "@coinslot/guard"
const guard = createGuard({ signer, telemetry: otel() })
guard.on("paid", (e) => console.log(e.traceId, e.amount, e.budgetLeft))Guides
Self-hosting
Both SDKs work without our server. With no apiKey set, nothing is sent to Coinslot: Paywall talks to your facilitator, Guard keeps its ledger on disk.
- Paywall: skip receipts and read settlements from your own logs, or from the PAYMENT-RESPONSE header.
- Guard: keep fileLedger on persistent storage and export traces with telemetry: otel().
- Alerts: subscribe to guard.on("blocked") and send them wherever you like.
- Kill switch: without the dashboard, stop the agent process or remove its signer.
The hosted dashboard is optional: it adds history, charts, alerts and a remote kill switch on top of the same events.
Security
Security FAQ
Do my private keys ever leave the agent?
No. Guard runs in your process and signs with the signer you pass in. Keys are never sent to Coinslot. If you enable the dashboard, Guard sends events only: amounts, addresses, rule decisions, trace ids, transaction hashes.
Do you hold or move funds?
No. Payments go from the agent's wallet to the seller's wallet, settled by the facilitator. Coinslot is never in the payment path and can't move, freeze or refund anyone's USDC.
What stops a seller from swapping its payTo address?
payTo pinning. After the first approved payment, Guard remembers each seller's address and refuses any 402 that asks to pay a different one. It's on by default.
What can the kill switch do?
Stop payments. It's a signal Guard checks before signing. It can't sign, can't send funds and can't change your policy.
Is it audited?
Not yet. Coinslot is experimental. Test on Base Sepolia or Solana devnet, keep agent wallets funded with only what a task needs, and report issues through GitHub security advisories.