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.

terminal
npx @coinslot/cli init

packages land on npm with v0.1 — until then install from the repo

Or install it by hand and add three lines:

terminal
npm i @coinslot/paywall
server.ts
import { 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:

terminal
npx @coinslot/cli test http://localhost:3000/v1/fact

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

terminal
npm i @coinslot/guard

packages land on npm with v0.1 — until then install from the repo

agent.ts
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:

agent.ts
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 idOptionBlocks when
kill_switchkillSwitchStop was pressed in the dashboard
budgettask(name, { budget })This payment would cross the task budget
per_callperCallThe 402 asks for more than the per-call max
dailydailyToday's spend would cross the daily cap
domain_listallow / deny .domainsThe domain is denied, or not on the allow-list
payto_listallow / deny .payToThe address is denied, or not on the allow-list
payto_pinnedpinPayTo (default true)The seller's payTo changed after first approval
approvalonNewSellerA new seller wasn't approved
not_paid_beforeledgerThis request was already paid; the retry re-sends that payment

budget

typescript
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 · budget

per_call

typescript
const guard = createGuard({ signer, perCall: "$0.01" })

// a 402 asking for $0.05 → blocked · per_call

daily

typescript
const guard = createGuard({ signer, daily: "$5" })

// one cap above every task, every call, all day

domain_list

Wildcards match subdomains. A deny entry always wins over an allow entry.

typescript
const guard = createGuard({
  signer,
  allow: { domains: ["api.example.com", "*.weather.dev"] },
  deny: { domains: ["ads.example.org"] },
})

payto_list

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

typescript
const guard = createGuard({ signer, pinPayTo: true }) // the default

// approved once: api.example.com → 0x4b…7e
// later a 402 asks for 0x9a…c3 → blocked · payto_pinned

approval

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.

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

typescript
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 one

kill_switch

typescript
const guard = createGuard({
  signer,
  killSwitch: { apiKey: process.env.COINSLOT_API_KEY },
})
// press Stop in the dashboard → next payment: blocked · kill_switch

Reference

Paywall options

The same options work for Hono, Express, Next.js (withPaywall) and MCP (paidTool).

typescript
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" },
})
OptionDefaultWhat it does
payTorequiredAddress that receives USDC
networkbasebase, base-sepolia, solana, solana-devnet, or a CAIP-2 id like eip155:8453
pricenoneOne price for every matched route, e.g. "$0.002"
routesnonePrice per route: { "GET /v1/fact": "$0.001" }
facilitatorcdpcdp, payai, thirdweb, or { url } for your own
rateLimitoff{ perPayer: 60, window: "1m" } per paying wallet
idempotencytrueA retry with the same payment returns the stored response, no second charge
receiptsoff{ apiKey } sends settled payments to the hosted dashboard
bazaaroff{ description } lists the endpoint in the x402 Bazaar

Next.js

app/api/fact/route.ts
// 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

mcp-server.ts
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

CommandWhat it does
npx @coinslot/cli initDetects 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
terminal
npx @coinslot/cli test https://api.example.com/v1/fact

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

typescript
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" } })
FacilitatoridNotes
Coinbase CDPcdpDefault. Base, Solana and more. Free monthly allowance, then a per-transaction fee billed to the seller
PayAIpayaiBase and Solana. See PayAI docs for fees
thirdwebthirdwebEVM 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.

EventWhenKey fields
requestThe agent calls a URLtask, url
payment_requiredA 402 came backamount, network, payTo
policy_checkEach rule ranrule, ok, detail
approval_requestedA new seller needs your okseller, payTo
paidA payment was signed and sentamount, budgetLeft
settledThe seller answered after paymentstatus, txHash, explorerUrl
replayedA retry was answered without a new chargeoriginalTraceId
blockedA rule refused the paymentrule, reason
errorSomething failedmessage
typescript
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.

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