Sell
Sell an HTTP endpoint
Publish a REST capability, then add @zanora/middleware-express to the route. Unpaid calls get a 402 with a signed price, and your handler only runs once the call is paid for.
Selling an HTTP endpoint takes two steps. You publish a capability that says what the endpoint is and what it costs, then you put the middleware in front of that URL.
Publish the capability
curl -s -X POST https://api.zanora.dev/v1/capabilities -H "x-api-key: $ZANORA_API_KEY" \
-H 'content-type: application/json' -d '{
"name": "Invoice OCR",
"description": "Extract text and structured line items from scanned invoices",
"protocol": "rest",
"priceMinor": "100",
"category": "OCR",
"endpoint": "https://acme.example/ocr",
"tags": ["invoices", "ocr"],
"schema": { "type": "object", "properties": { "imageUrl": { "type": "string" } }, "required": ["imageUrl"] },
"latencyP50Ms": 900
}'
# → { "capability": { "id": "cap_d2383f37…", … }, "version": { … } }Seller → Publish → Protocol: REST → name, description, category, price, endpoint URL → PublishpriceMinor is a string of cents. Leave out providerId, because it comes from your key. Keep the returned cap_… id: the middleware needs it.
Add the middleware
npm install @zanora/middleware-express expressimport express from "express";
import { HttpBackend, zanora } from "@zanora/middleware-express";
const app = express().use(express.json());
const backend = new HttpBackend({ apiKey: process.env.ZANORA_API_KEY! }); // gateway defaults to https://api.zanora.dev
app.post("/ocr", zanora({ backend, capabilityId: "cap_d2383f37…" }), (req, res) => {
// Only reached once the call is paid for.
// req.zanora = { transactionId, walletId, agentId }
res.json(runOcr(req.body.imageUrl));
});
app.listen(4024);Check the handshake
curl -si -X POST https://acme.example/ocr -H 'content-type: application/json' -d '{"imageUrl":"x"}'
# HTTP/1.1 402 Payment Required
# {"error":{"code":"PAYMENT_REQUIRED","message":"payment required"},
# "challenge":{"capabilityId":"cap_…","providerId":"prv_…","amountMinor":"100","currency":"USD","nonce":"…","expiresAt":"…","payTo":"wal_…","signature":"…","signingKeyId":"…"}}A 402 with a signed challenge means you're ready. Once you're verified, buyers can find and pay for it.
What the middleware does
| Request | Middleware does |
|---|---|
no x-payment header | asks Zanora for a signed challenge and returns 402 with it |
| with a payment proof | asks Zanora to authorize it (signature, replay, the buyer's policy, budget and balance), then calls your handler |
handler responds 2xx/3xx | sends the response bytes to Zanora, gets a signed receipt, returns it to the buyer in x-zanora-receipt |
handler responds 4xx | tells Zanora the call failed as a client_error: the buyer is refunded, and it doesn't count against you on the circuit breaker |
handler throws or responds 5xx | tells Zanora the call failed as a provider_error: the buyer is refunded, and it counts toward pausing that buyer's wallet from you (three in a row) |
The receipt's responseHash is the hash of the exact bytes you sent, and buyers check it. Don't add anything that changes the body per response (like a timestamp) after the middleware has hashed it.
If your process dies mid-call, Zanora keeps the in-flight transaction. A complete that arrives after a restart still counts. If nothing ever completes it, the buyer is refunded after 15 minutes.
Other frameworks and languages
zanora() is short, and most of it is calls to four gateway routes with your provider key (payments:write). To support FastAPI, Go, Rails or anything else, implement the same sequence. See x402 protocol.
Restarts and reuse
The capability and your server are separate. Publishing again creates a second capability. On restart, reuse the id you stored, or look it up in your own listings:
curl -s https://api.zanora.dev/v1/capabilities -H "x-api-key: $ZANORA_API_KEY"To change the price or wording without a new id, edit the listing.
Common mistakes
| Symptom | Cause |
|---|---|
buyers see INVOKE_FAILED — fetch failed | the endpoint isn't reachable from where the buyer's agent runs (a localhost URL, or a firewall). Zanora never calls you: the agent does |
402 forever, or PROVIDER_NOT_FOUND on complete | the middleware's HttpBackend points at a different gateway than the one the capability was published on |
| the endpoint serves calls for free | the route isn't wrapped in zanora(...). Publishing alone doesn't charge anything |
VALIDATION_FAILED: protocol must be one of: rest, mcp | protocol and category are required; priceMinor must be a string |