Accept payments
Charge AI agents per call in USDC for any HTTP endpoint or MCP tool
You ship a paid HTTP endpoint or MCP tool. Loomal handles the x402 dance: serves a 402 challenge, verifies the buyer's signed USDC authorization, settles on Base, and signs an Ed25519 receipt. This page is the end-to-end seller flow.
Before you start
- A project at console.loomal.ai with Pay → Accept turned on.
- Your
loid-...API key from the project dashboard. - The SDK:
npm install @loomal/sdk(Node/Bun/Deno) orpip install "loomal-sdk[fastapi]"(Python).
The five-line paywall
import express from "express";
import { Loomal } from "@loomal/sdk";
import { requirePayment } from "@loomal/sdk/paywall/express";
const app = express();
app.set("trust proxy", true);
const loomal = new Loomal({ apiKey: process.env.LOOMAL_API_KEY! });
app.get(
"/search",
requirePayment({ amount: "0.05" }),
(req, res) => res.json({ results: [/* ... */] }),
);
app.listen(3030);That's the whole integration. Set LOOMAL_API_KEY=loid-... and run. The middleware reads the env var automatically; requirePayment does the 402 challenge and the verify-and-settle step.
Other frameworks
Same shape, different import:
import { Hono } from "hono";
import { requirePayment } from "@loomal/sdk/paywall/hono";
const app = new Hono();
app.get("/search", requirePayment({ amount: "0.05" }), (c) => c.json({ results: [] }));import { requirePayment } from "@loomal/sdk/paywall/mcp";
server.tool(
"search",
{ description: "Paid search" },
requirePayment({ amount: "0.05" }, async (args) => ({ results: [/* ... */] })),
);from fastapi import FastAPI, Depends
from loomal.paywall import require_payment
app = FastAPI()
@app.get("/search", dependencies=[Depends(require_payment(amount="0.05"))])
def search():
return {"results": [...]}Without middleware (custom framework)
For Fastify, NestJS, Next.js, or any framework Loomal doesn't ship middleware for, wire the two SDK calls into your handler:
app.get("/search", async (req, res) => {
const resource = `${req.protocol}://${req.get("host")}${req.path}`;
const xPayment = req.get("x-payment");
if (!xPayment) {
const challenge = await loomal.payments.challenge({ amount: "0.05", resource });
return res.status(402).json(challenge);
}
const result = await loomal.payments.redeem({
paymentHeader: xPayment, resource, amount: "0.05",
});
if (!result.ok) return res.status(402).json(result.requirement);
res.setHeader("X-Payment-Response", result.paymentResponse);
res.json({ results: [/* ... */] });
});This is exactly what requirePayment does internally. Same SDK client, no extra dependencies, works on any HTTP server.
Test locally
api.loomal.ai fetches your URL from the public internet to drive the buyer side, so localhost won't work. Expose your server with a tunnel:
ngrok http 3030
# Forwarding https://abcd-1234.ngrok-free.app -> http://localhost:3030Two things keep the URL the buyer signs in sync:
app.set("trust proxy", true)— Express reportshttps://(nothttp://) behind a tunnel.- In console.loomal.ai → your project → Pay → Add endpoint, register the ngrok URL (exact match: host + path).
Sanity check:
curl -i https://abcd-1234.ngrok-free.app/search
# HTTP/1.1 402 Payment Required
# { "x402Version": 1, "accepts": [{ ... }] }See payments programmatically
const recent = await loomal.payments.list({ limit: 20 });
const one = await loomal.payments.get("cmoh...");
console.log(one.signedReceipt); // Ed25519-signed proof of paymentSame data the Pay tab shows. Use for in-product receipts, reconciliation, or your own admin views.
Webhooks
In console.loomal.ai → your project → Pay → Endpoints, edit the endpoint and add a webhookUrl. Loomal generates a whsec_… signing secret and shows it once — copy it then.
Loomal POSTs payment.received to your URL on every successful settle. Body is the Ed25519-signed receipt; X-Loomal-Signature: sha256=<hex> is HMAC-SHA256 over the raw body. Companion headers: X-Loomal-Event, X-Loomal-Idempotency-Key (de-dupe on this).
import express from "express";
import { verifyWebhook } from "@loomal/sdk/webhook";
app.post(
"/webhooks/loomal",
express.raw({ type: "application/json" }),
async (req, res) => {
const ok = await verifyWebhook(
req.body.toString(),
req.header("x-loomal-signature"),
process.env.LOOMAL_WEBHOOK_SECRET!,
);
if (!ok) return res.status(400).send("invalid signature");
// de-dupe on x-loomal-idempotency-key, then handle the receipt
res.sendStatus(200);
},
);from loomal.webhook import verify_webhook
@app.post("/webhooks/loomal")
async def webhook(request: Request):
raw = await request.body()
ok = verify_webhook(
raw, request.headers.get("x-loomal-signature"),
os.environ["LOOMAL_WEBHOOK_SECRET"],
)
if not ok:
raise HTTPException(400, "invalid signature")
# handle event ...Retry policy on delivery: 5xx and network errors back off (1s · 4s · 16s · 64s · 300s). 4xx aborts retries.