# Introduction > Zanora is the commerce layer for AI agents. Sellers make an API or MCP tool payable per call. Buyers' agents find it and pay from their own wallets, within spending rules a person sets. An AI agent that needs OCR, a data lookup or a model call usually hits a signup form, a credit card field and an API key it has to be given. Zanora removes those steps. A **seller** puts a price on an HTTP endpoint or MCP tool. A **buyer's agent** finds it, pays for one call from its own wallet and gets the answer back with a signed receipt. Money moves only when the call succeeds, and only within the limits the buyer's organisation set. - [I want my agent to buy things](https://docs.zanora.dev/buyers/overview.md): Give an agent a wallet and spending rules, then let it discover and pay for capabilities. Works from Claude, Cursor or any MCP client with no code, or from TypeScript with the SDK. - [I want to sell an API or tool](https://docs.zanora.dev/sellers/overview.md): Put a price on an HTTP endpoint or an MCP tool. You add one middleware line or wrapper and write no billing code. Earnings settle to your wallet per call, and you pay them out to a bank account or USDC. ## Start in five minutes - [Quickstart](https://docs.zanora.dev/quickstart.md): Buy a call from the live demo seller, end to end. - [How Zanora works](https://docs.zanora.dev/how-it-works.md): The payment handshake, and how settlement and refunds work. - [Building with AI agents](https://docs.zanora.dev/agents.md): `llms.txt`, Markdown pages, and a recipe an agent can follow to onboard itself. ## What you get | | For buyers | For sellers | |---|---|---| | **Identity** | a *workspace*, with one wallet per agent | a *provider*, with a settlement wallet | | **Integration** | an MCP config block, or `@zanora/sdk` | `@zanora/middleware-mcp` or `@zanora/middleware-express` | | **Money** | fund by card, bank or USDC and spend per call | paid per call, less the platform fee; payouts to bank or USDC | | **Control** | spending policies, daily budgets, human approvals | set prices, edit listings, see every sale | | **Proof** | a signed receipt for every call, bound to the answer | the same receipt for every sale | ## The hosted platform Everything in these docs works against the hosted platform by default: | | URL | What it is | |---|---|---| | Gateway | `https://api.zanora.dev` | the API, used by the SDK and the MCP packages | | Console | `https://console.zanora.dev` | the web app for signing up, funding wallets, approving spends, publishing and payouts | | Demo seller | `https://demo-seller.zanora.dev` | a verified seller offering the same OCR function over REST (`/ocr`) and over MCP (`/mcp`) | | Docs | `https://docs.zanora.dev` | this site. Every page is also available as Markdown | > **Note — How to read these docs:** > > Each tab is written for one reader. **Buyers** and **Sellers** each start with an overview and a quickstart, then take setup and usage in the order you'll do them. **Get started** covers ideas that apply to both. **API reference** lists every route a buyer or seller can call. Every page has a Markdown version: add `.md` to the URL, or use **Copy page**. --- # Quickstart > Pay for your first capability in about five minutes. You sign up as a buyer, give an agent a funded wallet, and buy one call from the live demo seller. This buys one call ($1.00) from the demo seller's invoice-OCR tool on the hosted platform. At the end you have an answer, a signed receipt, and a wallet balance that dropped by exactly the price. You'll use the [console](https://console.zanora.dev) for the steps a person has to do, and an MCP client for the rest. > **Warning — Packages are pre-release:** > > Step 5 installs `@zanora/mcp` from npm. If `npx` says the package isn't found, it hasn't been published yet. See [Packages → Status](https://docs.zanora.dev/packages.md#status). ### Step 1: Create a buyer account Open [console.zanora.dev](https://console.zanora.dev/#signup), choose **Buyer**, and enter your email, an organisation name and a password. Click the link in the email that arrives. The console confirms the address and shows your **workspace API key** (`zk.akey_…`). > **Warning — The key is shown once:** > > Only a hash of the key is stored. Copy it into a password manager now. If you lose it, you mint a new one. See [Accounts and API keys](https://docs.zanora.dev/concepts/accounts-and-keys.md). ### Step 2: Create an agent wallet In the console, go to **Buyer → Agents** and create an agent, for example `research-agent`. This creates a wallet and a signing keypair for it. Save the private key it shows as `agent-key.pem`. The key signs every payment the agent makes, and it is shown only once. ### Step 3: Fund the wallet Open the wallet and click **Fund**. Use a card top-up or send USDC on Base to the wallet's deposit address. The balance updates when the payment rail confirms, which can take a few seconds for a card and a few minutes for USDC. ### Step 4: Set a spending rule (optional) Go to **Buyer → Policies** and add this rule: ```yaml approval: price > 5.00 ``` Any call over $5.00 now waits for a person to approve it. The rule is checked on the server before money moves, so the agent can't get around it. See [Spending policies](https://docs.zanora.dev/buyers/policies.md). ### Step 5: Connect your agent Add Zanora to your MCP client's config. For Claude Desktop that's `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json title="claude_desktop_config.json" { "mcpServers": { "zanora": { "command": "npx", "args": ["-y", "@zanora/mcp"], "env": { "ZANORA_API_KEY": "zk.akey_…", "ZANORA_AGENT_KEY_FILE": "/absolute/path/to/agent-key.pem", "ZANORA_MAX_PRICE_MINOR": "500" } } } } ``` The gateway defaults to `https://api.zanora.dev`. If your workspace has exactly one agent wallet, the server finds it from the key. `ZANORA_MAX_PRICE_MINOR` caps each call at $5.00 on your side. ### Step 6: Buy something Restart the client and ask: ```text Find a capability that extracts text from invoices and run it on https://example.com/invoice.png. Tell me what it cost and show the receipt id. ``` The agent calls `zanora_discover`, which finds **Invoice OCR (demo)**, then `zanora_invoke`, which pays $1.00 and returns the result with a signed receipt. Check the wallet in the console: its balance is down by exactly `100` minor units. ## Prefer code? The same purchase from TypeScript with [`@zanora/sdk`](https://docs.zanora.dev/buyers/sdk.md): ```ts title="buy.ts" import { readFileSync } from "node:fs"; import { ZanoraAgent } from "@zanora/sdk"; const agent = new ZanoraAgent({ // gatewayUrl defaults to https://api.zanora.dev apiKey: process.env.ZANORA_API_KEY!, // the workspace key walletId: "wal_…", // from the console agentId: "research-agent", privateKeyPem: readFileSync("agent-key.pem", "utf8"), maxPriceMinor: 500n, // never sign a proof above $5.00 }); const { results } = await agent.discover({ query: "extract text from invoices", protocol: "rest" }); const r = await agent.invoke(results[0]!.version.endpoint, { body: { imageUrl: "https://example.com/invoice.png" }, }); console.log(r.data); console.log(r.receipt?.id, r.receiptVerified, r.responseHashVerified); ``` ## Next - [Buyer guide](https://docs.zanora.dev/buyers/overview.md): Workspaces, wallets, funding, policies and approvals in depth. - [Seller quickstart](https://docs.zanora.dev/sellers/quickstart.md): Sell your own endpoint or tool. The demo seller you just paid is about 30 lines of code. --- # How Zanora works > Who is involved, what happens during one paid call, and where the money goes afterwards. The flow is the same over HTTP and MCP. ## Who is involved | | Who they are | What they hold | |---|---|---| | **Seller** (a *provider*) | anyone with an HTTP endpoint or MCP tool worth paying for | a provider API key and a **settlement wallet** where earnings land | | **Buyer** (a *workspace*) | an organisation whose agents spend money | a workspace API key, one **agent wallet** per agent, and the **spending policies** those wallets follow | | **Agent** | the software doing the buying: a model in an MCP client, or your own code | its wallet's **private signing key**, which signs each payment | | **Zanora** | the gateway at `api.zanora.dev` | the registry, the ledger, the policy engine and the signing keys for challenges and receipts | Zanora never calls a seller. The **agent calls the seller directly**, and the seller's middleware checks with Zanora before running the handler. That's why a seller's URL has to be reachable from wherever buying agents run, not just from Zanora. ## One paid call The protocol is [x402](https://docs.zanora.dev/api/x402.md), named after HTTP's `402 Payment Required`: ```text Agent Seller (middleware) Zanora gateway │ 1. call, no payment ───► │ │ │ │ ── asks for a signed quote ──────► │ │ ◄── 2. 402 + challenge ─── │ ◄─ price, nonce, expiry, signed ── │ │ │ │ │ 3. verify the challenge, sign a payment proof with the wallet key │ │ │ │ 4. call again + proof ───► │ ── authorize(proof) ─────────────► │ signature, replay, │ │ │ policy, budget, balance │ │ ◄─ ok: money held ──────────────── │ │ │ 5. your handler runs │ │ │ ── complete(response) ───────────► │ receipt signed over │ ◄── 6. answer + receipt ── │ ◄─ signed receipt ──────────────── │ sha256(response) ``` 1. The agent calls the seller's endpoint or tool as usual. 2. The seller's middleware returns a **challenge**: the price, a single-use nonce and an expiry, **signed by Zanora**. Zanora sets the price the seller published, so the seller's server can't quote a different one on the fly. 3. The agent checks the challenge signature against Zanora's public key and refuses a price above its own ceiling. Then it signs a **payment proof** with its wallet's ed25519 key. 4. The agent retries with the proof attached. The middleware asks Zanora to **authorize** it. Zanora checks the signature against the wallet's registered public key, rejects replays, applies the buyer's **policy** and **daily budget**, and checks the balance. Then it takes the money from the wallet and holds it. 5. The handler runs. It only runs if the payment was authorized. 6. Zanora signs a **receipt** over a hash of the handler's response, and the agent gets the answer and the receipt together. With an SDK or MCP package, all of that is one function call or one tool call. You only handle it yourself if you [write your own middleware](https://docs.zanora.dev/api/x402.md). ## After the call - **Settlement is asynchronous.** The receipt comes back with the answer. The split into the seller's net and the platform fee happens moments later, as one balanced ledger movement. With the default fee of 3%, a $1.00 call pays the seller $0.97. The split is exact to the cent: fee plus net always equals the price. - **Failures are refunded automatically.** If the handler throws, responds with an HTTP error status (400 or above), or returns an MCP result with `isError`, the call fails. The buyer gets a `failed` receipt and the held money comes back as a new refund entry. Sellers are never paid for calls they didn't serve. - **Abandoned calls are refunded too.** If a seller authorizes a call and never completes it, Zanora refunds the buyer after 15 minutes. - **Repeated seller failures pause a seller for that wallet.** After three charged failures in a row that are the seller's fault (a 5xx, a thrown handler, an MCP `isError`, or a timeout) between one wallet and one seller, calls between them are refused before any money moves. A 4xx is refunded but doesn't count, because it means the buyer's request was bad, not that the seller is broken. The pause lifts on its own after a while, or the buyer can [lift it](https://docs.zanora.dev/api/wallets.md#post-v1-wallets-id-resume-provider). ## Where the money lives Every movement is a pair of entries in an **append-only, double-entry ledger**. A balance is always calculated from the entries and is never stored as a separate number. Nothing is edited or deleted. A refund or correction is a new entry that offsets the old one. Your wallet's ledger (`GET /v1/wallets/:id/ledger`) is the full history of every amount that moved. Money enters through a **funding rail**: a card or bank payment, or USDC on Base. It leaves through a **payout rail**: a bank transfer or USDC. Payments between buyers and sellers never touch a blockchain or a card network. They are ledger entries, which is why a call can cost a cent. See [Money and amounts](https://docs.zanora.dev/concepts/money.md). ## REST and MCP are the same protocol A capability is either an **HTTP endpoint** (`protocol: "rest"`) or a **tool on an MCP server** (`protocol: "mcp"`). The x402 handshake, the ledger, the receipts and the refunds are identical for both. Only the transport differs: | | HTTP | MCP | |---|---|---| | Price quote | `402` status + `{ challenge }` body | error result + `_meta["zanora.dev/x402-challenge"]` | | Payment | `x-payment` header | `params._meta["zanora.dev/x402-payment"]` | | Receipt | `x-zanora-receipt` header | `result._meta["zanora.dev/x402-receipt"]` | The live demo seller sells one OCR function both ways, at the same price. Buy both and you'll get the same answer and the same `responseHash`. --- # Building with AI agents > How an AI agent should read these docs, which onboarding steps it can do alone and which need a person, and the rules it should follow when money is involved. These docs are written to be read by agents as well as people. If you are an agent setting up Zanora for a user, start here. ## Machine-readable entry points | Resource | What it is | |---|---| | [`/llms.txt`](https://docs.zanora.dev/llms.txt) | the index: every page with a one-line summary, grouped the same way as the sidebar | | [`/llms-full.txt`](https://docs.zanora.dev/llms-full.txt) | every page's Markdown in one file | | `/.md` | a single page as plain Markdown, e.g. [`/buyers/sdk.md`](https://docs.zanora.dev/buyers/sdk.md). Links inside point to other `.md` pages | | `https://api.zanora.dev/v1/auth/scopes` | the full list of API key scopes, live | | `https://api.zanora.dev/v1/rail/providers` | what this deployment can do for funding and payouts, live | ## What an agent can do alone, and what needs a person Some steps are designed to need a person. Don't try to work around them. Tell your user what to do and wait. | Step | Who | Why | |---|---|---| | Request a signup (`POST /v1/signup`) | agent | no credential needed | | **Confirm the email** | **person** | the token goes to their inbox. Nothing is created until the address is confirmed | | Create wallets, set policies, publish capabilities | agent, with the tenant's key | ordinary API calls | | **Fund a wallet** | **person** | a card payment or a USDC transfer. An agent can't fund its own wallet | | **Approve a spend** that a policy held | **person** | in the console, or through an MCP prompt they answer | | **Verify a seller** | **Zanora operator** | until then the seller's capabilities don't appear in discovery | | **Set a payout destination** | **person** | changing it freezes payouts for 24 hours and sends an alert | | Buy, read receipts, rate sellers | agent | the normal loop | ## Onboarding recipe For an agent with shell access and a user who can read their email. All amounts are strings of minor units: `"100"` is $1.00. ### Step 1: Ask the user which role they need **Buyer**: an agent that pays for capabilities. **Seller**: an endpoint or tool that others pay for. One organisation can be both, with two signups under the same email. ### Step 2: Request the signup ```bash curl -s -X POST https://api.zanora.dev/v1/signup -H 'content-type: application/json' \ -d '{"role":"buyer","email":"USER_EMAIL","organizationName":"ORG_NAME"}' # → 202 {"signupId":"sgn_…","status":"pending","expiresAt":"…","emailSent":true} ``` Tell the user: *"Check your email for a Zanora link. Open it, and it will show you an API key once. Paste the key back to me, or keep it in your password manager and set `ZANORA_API_KEY`."* ### Step 3: Confirm the key works ```bash curl -s https://api.zanora.dev/v1/whoami -H "x-api-key: $ZANORA_API_KEY" # → {"principal":"workspace(wsp_…)","kind":"workspace","workspaceId":"wsp_…","scopes":[…],"keyId":"akey_…"} ``` ### Step 4: Follow the quickstart for the role Buyers: [Buyer quickstart](https://docs.zanora.dev/buyers/quickstart.md). Sellers: [Seller quickstart](https://docs.zanora.dev/sellers/quickstart.md). Both mark the steps that need a person. ## Rules for handling money > **Danger — Read these before you spend:** > > - **Money is never a JSON number.** Send `"priceMinor": "100"`. Sending `100` is rejected with a `400`, not rounded. > - **Never retry `POLICY_DENIED`.** A rule said no. Choose a cheaper or different capability, or tell the user. > - **`APPROVAL_REQUIRED` means stop.** Report the `approvalId` and wait for a person. Check progress with `zanora_approval_status` or `GET /v1/approvals`. `APPROVAL_DENIED` means a person said no. Don't ask again. > - **`INSUFFICIENT_FUNDS` means stop buying.** Tell the user where to send money (`zanora_deposit_addresses`, or the console). > - **Never paste a private key into chat or a config value.** Reference it by path (`ZANORA_AGENT_KEY_FILE`). It signs payments. > - **Use the endpoint the registry gives you.** Invoke by `capabilityId` or by the address discovery returned. Never build a seller URL yourself. > - **Rate what you buy.** `zanora_rate_provider` feeds the reputation that discovery ranks on. ## Getting help If a step fails in a way these docs don't cover, tell your user to email [support@zanora.dev](mailto:support@zanora.dev) with the tenant id (`wsp_…` or `prv_…`) and any `errorId`, `transactionId` or `approvalId`. Never include an API key or private key. ## Giving an agent Zanora as a tool The simplest setup is the MCP server. It provides ten tools (`zanora_discover`, `zanora_invoke`, balances, receipts, ratings and more), and its instructions tell the model the rules above. See [Buy from an MCP client](https://docs.zanora.dev/buyers/mcp.md). For selling, the same package has a [seller profile](https://docs.zanora.dev/sellers/no-code.md). --- # Accounts and API keys > Tenants, API keys, scopes and console logins. Everything that authenticates you to Zanora, and how to keep it narrow. ## Tenants Everything you own on Zanora belongs to a **tenant**. The type depends on your role: | Role | Tenant | Id prefix | Created with | |---|---|---|---| | Buyer | **workspace**: the unit of budget and policy, holding agent wallets | `wsp_…` | `POST /v1/signup` with `role: "buyer"` | | Seller | **provider**: the unit of listings and earnings, with one settlement wallet | `prv_…` | `POST /v1/signup` with `role: "seller"` | A tenant's id comes **from its credential**. You almost never send `workspaceId` or `providerId` in a request body. If you do, it is ignored for anything except an admin key. This means a leaked request can't be replayed against someone else's tenant. ## API keys An API key looks like `zk.akey_3652ec9e….` and goes in the `x-api-key` header on every call: ```bash curl -s https://api.zanora.dev/v1/whoami -H "x-api-key: $ZANORA_API_KEY" ``` ```json { "principal": "workspace(wsp_91c2…)", "kind": "workspace", "workspaceId": "wsp_91c2…", "scopes": ["wallets:read", "wallets:write", "policies:write", "…"], "keyId": "akey_3652ec9e…" } ``` Make `/v1/whoami` your first call with any new key. It tells you whether the key works and what it's allowed to do. > **Warning — Keys are shown once:** > > Zanora stores only `sha256(token)`. A lost key can't be recovered, only replaced: mint a new one from another key, or from the console (**Settings → API keys**) after signing in with your password. ### Your root key and the keys you mint from it Signup gives you a **root key** with every scope your role can use. Use it to set up, not to run production. Mint a narrower key for each service or agent: ```bash curl -s -X POST https://api.zanora.dev/v1/auth/keys -H "x-api-key: $ROOT_KEY" \ -H 'content-type: application/json' \ -d '{"label":"invoice-agent","scopes":["discovery:read","wallets:read","receipts:read","ratings:write"],"expiresInSeconds":2592000}' ``` The rules: - **Delegation only narrows.** A new key's scopes must be a subset of the scopes on the key that minted it, and it can't outlive that key. - **A key's scopes are fixed when it's minted.** If a release adds a new scope, existing keys don't get it. Mint a replacement if you need it. - **Rotation doesn't need downtime.** `POST /v1/auth/keys/:id/rotate` with `{"graceSeconds":300}` keeps the old secret working for five minutes while you redeploy. - **Revoking takes effect on the next request.** `DELETE /v1/auth/keys/:id?cascade=true` also revokes every key minted from it. Use that when a key has leaked. ## Scopes A **scope** decides which routes a key can call. **Ownership** decides which records it can reach. These are separate checks, and you need both. A provider key with `wallets:read` still can't read a buyer's wallet. | Scope | Buyer root key | Seller root key | Allows | |---|:-:|:-:|---| | `discovery:read` | ✓ | ✓ | search the marketplace | | `capabilities:read` | ✓ | ✓ | read capabilities; sellers list their own | | `capabilities:write` | | ✓ | publish, edit and deprecate capabilities | | `providers:read` | ✓ | ✓ | read provider profiles; request verification | | `workspaces:read` | ✓ | | read your workspace | | `wallets:read` / `wallets:write` | ✓ | ✓ | read balances and ledgers; create and fund wallets | | `policies:read` / `policies:write` | ✓ | | read and change spending rules | | `approvals:read` / `approvals:write` | ✓ | | see held spends; approve or deny them | | `ratings:write` | ✓ | | rate a seller you bought from | | `receipts:read` | ✓ | ✓ | read receipts: purchases for buyers, sales for sellers | | `payments:write` | | ✓ | the x402 routes a seller's middleware calls | | `payouts:write` | | ✓ | set a payout destination; request payouts | | `rail:read` | ✓ | ✓ | what funding and payout rails exist; your rail transactions | | `keys:read` / `keys:write` | ✓ | ✓ | list, mint, rotate and revoke keys | `GET /v1/auth/scopes` returns the full list. When a key is missing a scope, the `403` names the scope it needed: ```text 403 {"error":{"code":"FORBIDDEN","message":"GET /v1/metrics requires the \"metrics:read\" scope (key ci-publish holds: capabilities:write)"}} ``` > **Tip — Give an agent a key without approvals:write:** > > If an agent's key can't record approvals, the model can't approve its own held spend, even if you've set up in-client approval prompts. See [Approvals](https://docs.zanora.dev/buyers/approvals.md). ## Console logins The [console](https://console.zanora.dev) uses an email and password. Signing in creates a **session**, which is an ordinary API key of kind `session`, held in an httpOnly cookie. It has exactly your tenant's permissions and no more. - **Resetting a password** revokes every console session but **no** API keys, so your production agents keep working. - **Signing out** revokes that session only. You can't use it to revoke an agent's API key by accident. - If you sign up through the API rather than the console, include `password` in the signup request if you want to be able to sign in to the console. The password only takes effect once the email is confirmed. --- # Money and amounts > How to write an amount, how prices and fees are calculated, and why a balance is always calculated from the ledger and never stored. ## Amounts are strings of minor units Every amount that crosses the API is a **string of integer minor units**, meaning cents for USD: | You mean | You send | |---|---| | $1.00 | `"100"` | | $0.05 | `"5"` | | $250.00 | `"25000"` | ```json { "priceMinor": "100", "amountMinor": "5000", "dailyBudgetMinor": "50000" } ``` > **Danger — A number is an error, not a guess:** > > `"priceMinor": 100` (a JSON number) is rejected with `400 VALIDATION_FAILED`. Floats can't represent every cent exactly, and a value that's silently rounded is how money goes missing. Keep amounts as strings in your code too. In TypeScript, use `bigint` when you need to do arithmetic. Every response uses the same format: `{"balanceMinor":"4900","availableMinor":"4900","currency":"USD"}`. The ledger's currency is USD. ### Prices in seller code When you declare a price in [`@zanora/middleware-mcp`](https://docs.zanora.dev/sellers/mcp.md), you can write it in either of two unambiguous forms: | Field | Example | Meaning | |---|---|---| | `price` | `"$1.00"` | dollars, with the `$` sign. Parsed as a string, never as a float | | `priceMinor` | `"100"` | cents | `price: "100"` is refused, because it could mean $100 or $1.00. ### Prices in policies [Spending policies](https://docs.zanora.dev/buyers/policies.md) are the one place that uses **dollars**: `price > 5` means more than $5.00. Policies are written by people, and `price > 500` to mean $5 would be a trap. ## Fees Zanora takes a platform fee on each paid call, **3% by default**. The seller gets the rest: | Buyer pays | Seller receives | Platform fee | |---|---|---| | `"100"` ($1.00) | `"97"` | `"3"` | | `"50"` ($0.50) | `"49"` | `"1"` | | `"10"` ($0.10) | `"10"` | `"0"` | The fee is rounded **down** to the cent, so any rounding goes to the seller. Fee plus net always equals the price exactly. The console's seller screens show the current fee. ## The ledger - **Append-only.** Entries are never edited or deleted. A refund is a new credit that offsets the original debit, so your history shows both. - **Double-entry.** Every movement is a set of entries that sum to zero, so money is never created or lost between wallets. - **Balances are calculated.** `GET /v1/wallets/:id/balance` adds up the ledger every time. `availableMinor` is the balance minus anything on hold, such as a payment that is being authorized. - **No personal data.** Ledger entries carry opaque ids only. To find out *what* a charge bought, look at the [receipt](https://docs.zanora.dev/concepts/receipts.md). ## Rails: how money gets in and out | Rail | Direction | Notes | |---|---|---| | `card` | in | a card top-up. It credits when the card network confirms | | `ach` | in | a bank debit | | `usdc_base` | in and out | USDC on Base. Deposits are credited 1:1 while USDC is within ±0.5% of $1; outside that band they are **held**, not credited at a guessed rate | | `bank_transfer` | out | seller payouts by ACH, wire or SEPA | Which rails exist depends on the deployment. **Ask rather than assume**: `GET /v1/rail/providers` lists what the gateway can do. See [Funding a wallet](https://docs.zanora.dev/buyers/funding.md) and [Payouts](https://docs.zanora.dev/sellers/payouts.md). --- # Capabilities and discovery > What a capability is, the two protocols and two kinds of address, how discovery ranks results, and why an unverified seller doesn't appear in it. A **capability** is one thing a buyer can pay for, called once per payment. It's either an HTTP endpoint or a single tool on an MCP server. ## The record ```json { "capability": { "id": "cap_d2383f37…", "providerId": "prv_3652ec9e…", "name": "Invoice OCR", "description": "Extract text and structured line items from scanned invoices", "category": "OCR", "protocol": "mcp", "toolName": "extract_invoice", "priceMinor": "100", "currency": "USD", "tags": ["invoices"], "schema": { "type": "object", "properties": { "imageUrl": { "type": "string" } }, "required": ["imageUrl"] }, "latencyP50Ms": 900, "status": "active" }, "version": { "id": "ver_…", "version": "1.0.0", "endpoint": "https://demo-seller.zanora.dev/mcp", "address": { "kind": "url", "url": "https://demo-seller.zanora.dev/mcp" }, "pricingModel": "per_call", "hash": "…" } } ``` | Field | Meaning | |---|---| | `protocol` | `"rest"` (an HTTP endpoint) or `"mcp"` (a tool on an MCP server) | | `toolName` | **required for `mcp`, and not allowed for `rest`.** One MCP server hosts many tools, so the URL alone doesn't say what was bought | | `priceMinor` | the price per call, as a string of cents | | `schema` | the request body's JSON Schema, when the seller published one. Read it before paying, so you don't pay for a call that fails with a `400` | | `latencyP50Ms` | the seller's advertised median response time, used in ranking | | `version.address` | where to reach it: a URL to call, or a package to run (see below). `version.endpoint` is the same thing as a display string | | `version.hash` | covers the fields that define what's being sold. Changing the schema publishes a new version | | `status` | `active`, `draft` or `deprecated`. Only `active` capabilities appear in discovery, but deprecated ids still resolve | ## Two kinds of address | `address.kind` | Buyer does | For | |---|---|---| | `url` | calls the URL (HTTP, or MCP over Streamable HTTP) | any hosted endpoint or MCP server | | `package` | **runs** a pinned npm package locally over stdio | an MCP server whose seller doesn't host it | A package address pins an exact version, such as `@acme/ocr-mcp@1.4.2`, because the buyer runs that code. Discovery marks these results `runsLocally: true`. Buyers don't run them unless they have explicitly allowed that package. See [Running seller packages](https://docs.zanora.dev/buyers/local-packages.md). > **Tip — Resolve the address; don't read `endpoint`:** > > `version.endpoint` is a display string. In code, call `resolveCapabilityAddress(version)` from `@zanora/core`. It returns the URL or the package, and your code handles each correctly. ## Discovery ```bash curl -s -X POST https://api.zanora.dev/v1/discovery/search -H "x-api-key: $KEY" \ -H 'content-type: application/json' \ -d '{"query":"extract text from invoices","maxPriceMinor":"200","protocol":"mcp","limit":5}' ``` Every filter is optional: `query`, `category`, `protocol`, `maxPriceMinor`, `maxLatencyMs`, `limit`. Each result has the capability, its version, its provider (`id`, `name`, `verified`, `reputation`) and a `score`. Results are **ranked** on: - **Relevance**: how closely the name, description and tags match your query. - **Price**: cheaper ranks higher, within your filter. - **Latency**: the seller's advertised median response time. - **Reputation**: ratings from buyers who hold a receipt for what they rated. - **Verification**: verified sellers rank higher. ### The verification gate > **Warning — Unverified sellers aren't in discovery:** > > A seller can publish as soon as they sign up, but their capabilities stay **out of discovery** until a Zanora operator verifies them. If a seller publishes correctly and searches return `{"results":[]}`, this is the usual reason. Sellers can ask for verification from the console or the API. See [Getting verified](https://docs.zanora.dev/sellers/verification.md). ## Changing a listing Sellers can edit a live capability's price, wording, tags, schema and advertised latency without changing its id. They **can't** change its address, protocol or tool name, because a buyer's saved capability id has to keep pointing at the same thing. Moving a capability means deprecating it and publishing a new one. Changing a price is safe even while buyers are mid-flow. Every call pays the price in the challenge Zanora signs at call time, never a price the buyer saw earlier. --- # Receipts > Every paid call produces a receipt signed by Zanora and bound to the exact response. What it contains, how to check it, and how to find old ones. ## What a receipt says ```json { "id": "rcp_6bd223c5…", "transactionId": "txn_498b5328…", "providerId": "prv_3652ec9e…", "consumerId": "wal_f33b5618…", "capabilityId": "cap_d2383f37…", "amount": { "amountMinor": "100", "currency": "USD" }, "responseHash": "9f2c…", "status": "success", "timestamp": "2026-09-24T10:12:03.114Z", "signature": "…", "signingKeyId": "rk_…" } ``` | Field | Meaning | |---|---| | `status` | `success`: charged and delivered. `failed`: the seller's handler failed and the buyer was refunded. `refunded`: refunded after the fact | | `responseHash` | `sha256` of the response the seller returned. For MCP, it covers the tool's `structuredContent` when there is one, and its `content` blocks otherwise | | `consumerId` | the agent wallet that paid | | `signature`, `signingKeyId` | ed25519 over the canonical JSON of every other field | Receipts contain opaque ids only: no names, emails or request bodies. ## Two checks, two claims Both SDKs and both MCP packages return two booleans with every purchase. **They're separate on purpose:** | Check | Claim | |---|---| | `receiptVerified` | Zanora signed this receipt, and it hasn't been changed | | `responseHashVerified` | this receipt is for **the answer you received**, not some other answer | A verified receipt with `responseHashVerified: false` means what arrived isn't what the seller was paid for. The answer may still be useful, but you can't cite the receipt as proof of it. ## Verifying a receipt yourself Receipts are signed over canonical JSON: keys sorted, amounts as strings, without `signature` and `signingKeyId`. Fetch the public key for the key id and verify: ```bash curl -s https://api.zanora.dev/v1/platform/receipt-keys/$SIGNING_KEY_ID # → { "keyId": "rk_…", "publicKeyPem": "-----BEGIN PUBLIC KEY-----…" } ``` Old keys stay available after rotation, so an old receipt can always be checked. In code, `agent.verifyReceipt(receipt)` in `@zanora/sdk`, or the `zanora_receipt` MCP tool, does this for you. ## Finding receipts | You want | Call | |---|---| | everything your workspace bought | `GET /v1/receipts` with a workspace key | | everything you sold | `GET /v1/receipts` with a provider key | | one receipt | `GET /v1/receipts/:id` | | all receipts for one transaction | `GET /v1/transactions/:id/receipts` | | from an MCP client | `zanora_purchases` (buyer) or `zanora_sales` (seller) | Lists are newest first. To page through, pass `?after=` and follow `nextCursor`. The cursor is tied to a row rather than an offset, so new sales arriving between pages can't make you skip any. Details are in the [API reference](https://docs.zanora.dev/api/receipts.md). --- # Errors and refusals > The error envelope, what each error code means, whether any money moved, and what to do next. The page to keep open while you integrate. ## The envelope Every error from the gateway has the same shape: ```json { "error": { "code": "POLICY_DENIED", "message": "denied by rule: price > 20", "details": { "matchedRule": "price > 20" } } } ``` Handle errors by `code`. The `message` is written for a person and can change between releases. An unexpected server error returns only `"internal error"` and an `errorId`. Quote the `errorId` when you ask for help. ## Refusals while buying These are the platform answering a purchase, not faults. **In every row except the last, no money moved.** | Code | HTTP | Means | Do this | |---|---|---|---| | `POLICY_DENIED` | 403 | a workspace rule, the daily budget, or a missing *allow* rule said no | **don't retry.** Pick another capability, or change the policy | | `APPROVAL_REQUIRED` | 403 | a rule needs a person; the response has an `approvalId` | stop and report the id. A person approves in the console, then retry **once** | | `APPROVAL_DENIED` | — | a person refused this spend (MCP tools) | don't retry, and don't ask again | | `PRICE_ABOVE_CLIENT_LIMIT` | — | the price is above your own `maxPriceMinor`. Your client refused before signing | raise your ceiling, or pick something cheaper | | `INSUFFICIENT_FUNDS` | 402 | the wallet's available balance is below the price | stop, and [fund the wallet](https://docs.zanora.dev/buyers/funding.md) | | `WALLET_FROZEN` | 403 | the wallet is frozen | unfreeze it (`POST /v1/wallets/:id/unfreeze`) | | `WALLET_PROVIDER_PAUSED` | 429 | three seller failures in a row (5xx, thrown handler, MCP `isError` or timeout) paused them for this wallet. 4xx responses don't count | wait, or [lift the pause](https://docs.zanora.dev/api/wallets.md#post-v1-wallets-id-resume-provider) | | `CHALLENGE_EXPIRED` | 410 | the signed price quote expired before payment | call again. The SDKs do this for you | | `REPLAY_DETECTED` | 409 | this payment proof was already used | nothing: the first use counted. Don't resend proofs | | `CANCELLED` | — | you aborted before the payment proof was sent | retry freely. Nothing was charged, and the SDK can only promise that for this code | | a `failed` receipt | 200 | you paid, the seller's handler failed, and you were **refunded** | the refund is a new ledger entry. Consider rating the seller | > **Note — Cancelling after payment:** > > Once the payment proof has been sent, cancelling no longer stops the purchase. The seller is already running, and dropping the connection would only throw away an answer you paid for. The call completes and appears in your purchase history. ## Errors while integrating | Code | HTTP | Usually | |---|---|---| | `VALIDATION_FAILED` | 400 | an amount sent as a number instead of a string, a missing required field, or an unknown enum value. The message names the field | | `UNAUTHORIZED` | 401 | a missing, expired, revoked or mistyped key. Run `GET /v1/whoami` | | `FORBIDDEN` | 403 | the key is missing a scope (the message names it), or the record isn't yours | | `NOT_FOUND`, `CAPABILITY_NOT_FOUND`, `PROVIDER_NOT_FOUND`, `WALLET_NOT_FOUND` | 404 | a wrong id, or one owned by another tenant | | `NOT_SUPPORTED` | 501 | this deployment has no rail for that request, e.g. card top-ups without a card processor. Ask `GET /v1/rail/providers` first | | `INSECURE_TRANSPORT` | 403 | the request reached the gateway over plain HTTP. Use `https://` | | `RATE_LIMITED` | 429 | too many requests for this key. Wait for the `retry-after` header | ## Errors about money moving in or out | Code | HTTP | Means | |---|---|---| | `DEPOSIT_HELD` | 409 | a USDC deposit arrived while USDC was outside ±0.5% of $1. It's held, not credited at a guessed rate, and is retried automatically | | `DESTINATION_COOLING_DOWN` | 409 | the payout destination changed less than 24 hours ago. Payouts resume on their own after that | | `SANCTIONED_ADDRESS` | 403 | sanctions screening blocked the payout destination | | `TREASURY_UNAVAILABLE` | 503 | a pricing or screening service is down, so the payout is paused rather than guessed. Retry later | | `COVERAGE_SHORTFALL` | 503 | payouts are paused while the platform reconciles its holdings. Retry later | | `DUPLICATE_EXTERNAL_REF` | 409 | this deposit was already credited. Duplicates are ignored | --- # Packages > The six npm packages. What each is for, who installs it, and which one to pick for your role and protocol. All six packages are TypeScript, ESM-only, licensed under Apache-2.0, and need **Node.js 20 or later**. ## Pick by role | You are | Protocol | Install | Docs | |---|---|---|---| | Buyer with an MCP client (Claude, Cursor…) | any | nothing: add `npx -y @zanora/mcp` to your config | [Buy from an MCP client](https://docs.zanora.dev/buyers/mcp.md) | | Buyer who already picked one seller | MCP | nothing: add `npx -y @zanora/mcp-proxy ` | [Use one seller's tools](https://docs.zanora.dev/buyers/mcp-proxy.md) | | Buyer writing code | REST or MCP | `npm install @zanora/sdk` | [Buy from code](https://docs.zanora.dev/buyers/sdk.md) | | Seller with an MCP server | MCP | `npm install @zanora/middleware-mcp @modelcontextprotocol/sdk` | [Sell an MCP tool](https://docs.zanora.dev/sellers/mcp.md) | | Seller with an Express app | REST | `npm install @zanora/middleware-express express` | [Sell an HTTP endpoint](https://docs.zanora.dev/sellers/rest.md) | | Seller who'd rather not write code | any | nothing: `@zanora/mcp` with `ZANORA_ROLE=seller` | [Manage listings from an MCP client](https://docs.zanora.dev/sellers/no-code.md) | ## The packages | Package | What it is | |---|---| | `@zanora/sdk` | the buyer's SDK. `ZanoraAgent` discovers, pays over x402 (HTTP and MCP), and verifies receipts. `ZanoraClient` wraps the other API routes | | `@zanora/mcp` | an MCP server with two profiles: a buyer's ten tools (discover, invoke, balance, receipts…) and a seller's eleven (publish, edit, verification, sales, earnings). Runs over stdio with `npx` | | `@zanora/mcp-proxy` | puts one paid MCP server in front of your agent so its tools appear under their own names, and pays as they're called | | `@zanora/middleware-mcp` | seller side, for MCP. `zanoraSeller().sell()` publishes a tool with a price and returns a wrapper for its handler. Works with any MCP server library | | `@zanora/middleware-express` | seller side, for HTTP. `zanora({ backend, capabilityId })` is Express middleware that handles the 402 handshake | | `@zanora/core` | shared types, amount handling, ed25519 signing and the x402-over-MCP encoding. Installed as a dependency of the others. You only need it directly to build against the protocol | ## Status > **Warning — Pre-release:** > > The packages are versioned at `0.1.x` and may not be on the public npm registry yet. Check with: > > ```bash > npm view @zanora/sdk version > ``` > > If that returns `404`, the packages haven't been published. You can still call the [HTTP API](https://docs.zanora.dev/api/overview.md) directly from any language. Everything the packages do goes through it. ## Other languages The gateway is plain HTTPS and JSON, so any language can **buy**. Payment proofs are ed25519 signatures over canonical JSON, and [x402 protocol](https://docs.zanora.dev/api/x402.md) documents the exact steps. For **selling**, the middleware only calls four routes on the gateway: challenge, authorize, complete and fail. A port to FastAPI, Go or anything else follows the same steps. --- # Hosted and local environments > The hosted platform is the default for everything. How to point a package at a different gateway, and how to run one locally for development. ## Hosted (default) | | URL | |---|---| | Gateway | `https://api.zanora.dev` | | Console | `https://console.zanora.dev` | | Demo seller, REST | `https://demo-seller.zanora.dev/ocr` (`POST`, body `{ "imageUrl": "…" }`, $1.00) | | Demo seller, MCP | `https://demo-seller.zanora.dev/mcp` (tool `extract_invoice`, $1.00) | | API health | `https://api.zanora.dev/health` | The hosted platform handles real money: accounts, listings, balances and receipts you create there are permanent. > **Tip — The demo seller is always available:** > > It's a verified seller selling the same OCR function over both protocols at the same price, so you can test a buying integration on day one before any other seller exists. The REST and MCP versions return the same answer and the same `responseHash`. ## Choosing the gateway Every package uses the hosted gateway, `https://api.zanora.dev`, unless you name another one. | Tool | Setting | Default | |---|---|---| | `@zanora/mcp`, `@zanora/mcp-proxy` | `ZANORA_GATEWAY_URL` | `https://api.zanora.dev` | | `@zanora/sdk` | `new ZanoraAgent({ gatewayUrl })`, `new ZanoraClient({ gatewayUrl })` | `https://api.zanora.dev` | | `@zanora/middleware-mcp` | `zanoraSeller({ gatewayUrl })`, or `ZANORA_GATEWAY_URL` | `https://api.zanora.dev` | | `@zanora/middleware-express` | `new HttpBackend({ gatewayUrl, apiKey })`, or positionally `new HttpBackend(gatewayUrl, apiKey)` | `https://api.zanora.dev` | | Starter scripts in the repository | `ZANORA_GATEWAY` | `https://api.zanora.dev` | A seller's middleware and the capability it serves must use the **same** gateway. If they don't, buyers get `402` forever, or the call fails with `PROVIDER_NOT_FOUND` when it completes. ## Running a gateway locally For development against a local gateway, with the platform repository checked out: ```bash PORT=8080 ZANORA_REQUIRE_AUTH=true ZANORA_API_KEYS=admin-boot \ ZANORA_SIGNUP_DEV_ECHO=true NODE_OPTIONS=--conditions=development \ npx tsx packages/platform/src/server.ts ``` - `ZANORA_REQUIRE_AUTH=true` makes keys identify a tenant, which is how the hosted platform behaves. - `ZANORA_API_KEYS=admin-boot` gives you an operator key, so you can verify your own test seller. - `ZANORA_SIGNUP_DEV_ECHO=true` returns the signup token in the response, so you don't need an inbox. **Local development only.** A local gateway keeps state in memory unless you set `ZANORA_DATABASE_URL`, and its payment rails are mocks. `POST /v1/wallets/:id/fund` credits a wallet instantly there. On the hosted platform that route returns `NOT_SUPPORTED`, because real funding has to come through a card or a transfer. ## Differences from the hosted platform | | Local gateway | Hosted platform | |---|---|---| | Signup token | in the response (dev echo) | emailed. The link opens the console | | Funding | `POST /v1/wallets/:id/fund`, instant | card top-up or USDC deposit, credited when the rail confirms | | Seller verification | you do it with the admin key | a Zanora operator does it | | Seller URL | `localhost` works if the agent is on the same machine | must be reachable from wherever buyers' agents run | --- # Buyer overview > What a buyer sets up once — a workspace, wallets, policies — and the three ways an agent can then buy. Pick your path here. As a buyer you give software the ability to pay for things, within limits you control. The model has three parts: | | What it is | You'll have | |---|---|---| | **Workspace** (`wsp_…`) | your organisation on Zanora: the unit of **budget and policy** | one, from signup | | **Wallet** (`wal_…`) | holds money, spends it per call | **one per agent**, so budgets and history stay separate | | **Agent** | the thing that spends: a model in an MCP client, or your code | an **ed25519 keypair**. The wallet holds the public half, and the agent holds the private half and signs each payment with it | Two credentials, two jobs: - The **workspace API key** (`zk.akey_…`) authenticates API calls: discovery, balances, receipts. - The **agent's private key** (`agent-key.pem`) signs payment proofs. It's the spending authority. The platform never has a copy. ## Set up once ### Step 1: Create a workspace Sign up at the console or with `POST /v1/signup`, and optionally set a daily budget. See [Create a workspace](https://docs.zanora.dev/buyers/signup.md). ### Step 2: Create a wallet for each agent Generate a keypair and register the public half with a new wallet. See [Wallets and agent keys](https://docs.zanora.dev/buyers/wallets.md). ### Step 3: Fund it Card top-up or USDC deposit. Money is credited when the payment rail confirms it. See [Funding a wallet](https://docs.zanora.dev/buyers/funding.md). ### Step 4: Set the rules Deny, require approval, or allow-list by price, category, seller or reputation. Rules are checked on the server before any money moves. See [Spending policies](https://docs.zanora.dev/buyers/policies.md). ## Then pick how the agent buys - [From an MCP client](https://docs.zanora.dev/buyers/mcp.md): Claude, Cursor or any MCP client. Add one config block and the model gets `zanora_discover` and `zanora_invoke`. **No code.** - [From your own code](https://docs.zanora.dev/buyers/sdk.md): `@zanora/sdk` in TypeScript: `discover()`, then `invoke()` or `invokeMcp()`. Typed results and receipt checks. - [One seller's tools, directly](https://docs.zanora.dev/buyers/mcp-proxy.md): Already chose a seller? Their tools show up under their own names and are paid for as they're called. The three can be combined. They use the same wallet, the same policies and the same receipts. ## What protects you - **Policies run on the server.** A model can't argue its way past a rule. A blocked purchase comes back as `POLICY_DENIED` or `APPROVAL_REQUIRED`, and nothing is charged. - **Your own ceiling.** `maxPriceMinor` (SDK) or `ZANORA_MAX_PRICE_MINOR` (MCP) makes your client refuse to sign anything above that price, whatever the seller asks. - **Refunds are automatic.** If the seller fails, you get a `failed` receipt and your money back. - **Every purchase is signed.** The receipt is signed by Zanora and bound to the exact answer you received. - **One wallet per agent** means a misbehaving agent can spend only what that wallet holds. --- # Buyer quickstart > From nothing to a verified purchase using only the API — signup, keypair, wallet, funding, policy, discover, pay. Every step is a copy-paste command. This walkthrough uses `curl` and one small script, so every step is visible. For a console-based walkthrough, see the [Quickstart](https://docs.zanora.dev/quickstart.md). Steps marked 👤 need a person. ```bash export GW=https://api.zanora.dev ``` ### Step 1: Request a workspace 👤 ```bash curl -s -X POST $GW/v1/signup -H 'content-type: application/json' -d '{ "role": "buyer", "email": "eng@northwind.example", "organizationName": "Northwind", "dailyBudgetMinor": "5000", "password": "a long passphrase for the console" }' # → 202 {"signupId":"sgn_…","status":"pending","expiresAt":"…","emailSent":true} ``` Open the emailed link. The console confirms the address and shows your root key **once**. Save it: ```bash export WKEY='zk.akey_….…' curl -s $GW/v1/whoami -H "x-api-key: $WKEY" # → "kind":"workspace","workspaceId":"wsp_…" ``` `dailyBudgetMinor: "5000"` caps the whole workspace at $50.00 a day. You can leave it out. ### Step 2: Generate the agent's keypair ```bash openssl genpkey -algorithm ed25519 -out agent-key.pem chmod 600 agent-key.pem openssl pkey -in agent-key.pem -pubout -out agent-key.pub.pem ``` `agent-key.pem` never leaves this machine. The platform only sees the public half. ### Step 3: Create the wallet ```bash curl -s -X POST $GW/v1/wallets -H "x-api-key: $WKEY" -H 'content-type: application/json' \ -d "$(jq -n --arg pem "$(cat agent-key.pub.pem)" '{ownerType:"agent", ownerId:"invoice-bot", publicKeyPem:$pem}')" # → {"id":"wal_f33b5618…","ownerType":"agent","ownerId":"invoice-bot","workspaceId":"wsp_…",…} export WAL=wal_f33b5618… ``` You don't send `workspaceId`. It comes from your key. ### Step 4: Fund it 👤 Check which funding options this deployment offers: ```bash curl -s $GW/v1/rail/providers -H "x-api-key: $WKEY" ``` Then either request a **top-up** (`topUps: true`) or get a **USDC deposit address** (`depositAddresses: true`): ```bash curl -s -X POST $GW/v1/wallets/$WAL/topup -H "x-api-key: $WKEY" \ -H 'content-type: application/json' -d '{"amountMinor":"2000"}' curl -s -X POST $GW/v1/wallets/$WAL/deposit-address -H "x-api-key: $WKEY" \ -H 'content-type: application/json' -d '{"asset":"USDC","chain":"base"}' ``` Money is credited when the rail confirms it. The console's **Fund** button does the same thing with a card form. Watch the balance: ```bash curl -s $GW/v1/wallets/$WAL/balance -H "x-api-key: $WKEY" # → {"balanceMinor":"2000","availableMinor":"2000","currency":"USD"} ``` ### Step 5: Set a policy ```bash curl -s -X POST $GW/v1/policies -H "x-api-key: $WKEY" -H 'content-type: application/json' \ -d '{"document":"approval:\n price > 5\ndeny:\n provider.verified == false"}' ``` Anything over $5.00 now needs a person to approve it, and unverified sellers are refused. ### Step 6: Discover and pay ```bash npm install @zanora/sdk ``` ```ts title="buy.ts" import { readFileSync } from "node:fs"; import { ZanoraAgent, ZanoraPolicyRejection } from "@zanora/sdk"; const agent = new ZanoraAgent({ apiKey: process.env.WKEY!, walletId: process.env.WAL!, agentId: "invoice-bot", privateKeyPem: readFileSync("agent-key.pem", "utf8"), maxPriceMinor: 500n, }); const { results } = await agent.discover({ query: "extract text from invoices", protocol: "rest", maxPriceMinor: "200" }); const best = results[0]; if (!best) throw new Error("nothing matched"); try { const r = await agent.invoke(best.version.endpoint, { body: { imageUrl: "https://example.com/invoice.png" } }); console.log(r.data); console.log("receipt", r.receipt?.id, "signed:", r.receiptVerified, "for this answer:", r.responseHashVerified); if (r.receipt) await agent.rateProvider(best.provider.id, r.receipt.transactionId, 5); } catch (err) { if (err instanceof ZanoraPolicyRejection) console.log("refused:", err.code, err.approvalId ?? ""); else throw err; } ``` ```bash WKEY=$WKEY WAL=$WAL npx tsx buy.ts ``` The balance drops by exactly the price, and `GET /v1/receipts` lists the purchase. > **Tip — Runnable version:** > > The platform repository has this whole flow as two scripts, with every refusal handled: `starters/d-buyer-rest` (`onboard.ts` for steps 2–5, `buy.ts` for step 6). --- # Create a workspace > Sign up as a buyer from the console or the API, confirm your email, and keep the root key safe. Nothing is created until the address is confirmed. ## From the console 1. Open [console.zanora.dev](https://console.zanora.dev/#signup) and choose **Buyer**. 2. Enter your email, organisation name and a password. 3. Click the link in the email. The console confirms the address, creates your workspace, and shows the **root API key once**. You can now sign in to the console with your email and password, and use the key from code. ## From the API ```bash curl -s -X POST https://api.zanora.dev/v1/signup -H 'content-type: application/json' -d '{ "role": "buyer", "email": "eng@northwind.example", "organizationName": "Northwind", "dailyBudgetMinor": "50000", "password": "optional — enables console sign-in" }' ``` | Field | Required | Meaning | |---|---|---| | `role` | yes | `"buyer"` | | `email` | yes | where the confirmation link goes | | `organizationName` | yes | shown in the console and on approvals | | `dailyBudgetMinor` | no | the workspace's daily spending cap, in cents (`"50000"` = $500) | | `password` | no | lets you sign in to the console. It only takes effect once the email is confirmed | | `website` | no | your organisation's site | The response is `202` with a `signupId`. **Nothing exists yet.** The link in the email carries a single-use token that expires after 24 hours. Opening it (or `POST /v1/signup/verify` with `{"token":"…"}`) creates the workspace and returns: ```json { "signupId": "sgn_…", "role": "buyer", "status": "completed", "tenant": { "kind": "workspace", "id": "wsp_…", "name": "Northwind" }, "credential": { "keyId": "akey_…", "token": "zk.akey_….…", "label": "…", "scopes": ["…"] } } ``` > **Warning — Save the token now:** > > `credential.token` is shown once. Only a hash of it is stored, so a lost key is replaced, never recovered. ### Didn't get the email? ```bash curl -s -X POST https://api.zanora.dev/v1/signup/resend -H 'content-type: application/json' \ -d '{"role":"buyer","email":"eng@northwind.example"}' ``` Resending invalidates the previous link. The response is the same whether or not the address is known, so this can't be used to find out who has an account. ## After signing up - Run `GET /v1/whoami` with the key to confirm it works. - **Mint narrower keys** for each agent or service, and keep the root key for setup. See [Accounts and API keys](https://docs.zanora.dev/concepts/accounts-and-keys.md). - Next: [create a wallet for your agent](https://docs.zanora.dev/buyers/wallets.md). --- # Wallets and agent keys > Each agent gets a wallet and an ed25519 keypair. The wallet holds the public key and the agent holds the private key. How to create both, and how to look after them. ## One wallet per agent A wallet holds money and belongs to your workspace. An **agent wallet** also holds the **public key** that its payment proofs are checked against. Give each agent its own wallet: - a compromised or misbehaving agent can only spend what its own wallet holds; - purchase history and receipts stay separate per agent; - the MCP packages can find the wallet from your key automatically, but only when the workspace has exactly one agent wallet. With several, you name the one to use. ## Create one in the console **Buyer → Agents → New agent.** The console generates the keypair, creates the wallet, and shows the private key **once** as a download. It also keeps a copy in that browser, so the console can make test calls. ## Create one with the API ### Step 1: Generate a keypair ```bash title="OpenSSL" openssl genpkey -algorithm ed25519 -out agent-key.pem chmod 600 agent-key.pem openssl pkey -in agent-key.pem -pubout # the public half, for the next step ``` ```ts title="TypeScript" import { generateEd25519KeyPair } from "@zanora/sdk"; const { publicKeyPem, privateKeyPem } = generateEd25519KeyPair(); // Store privateKeyPem somewhere only the agent can read. ``` ```bash title="Node, no packages" node -e 'const {generateKeyPairSync}=require("node:crypto"); const {publicKey,privateKey}=generateKeyPairSync("ed25519"); require("node:fs").writeFileSync("agent-key.pem",privateKey.export({type:"pkcs8",format:"pem"}),{mode:0o600}); console.log(publicKey.export({type:"spki",format:"pem"}))' ``` ### Step 2: Register the wallet ```bash curl -s -X POST https://api.zanora.dev/v1/wallets -H "x-api-key: $WKEY" \ -H 'content-type: application/json' \ -d '{"ownerType":"agent","ownerId":"invoice-bot","publicKeyPem":"-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"}' ``` `ownerId` is your label for the agent. It appears in payment proofs, so use something plain like `invoice-bot`. The response is the wallet, including its `id` (`wal_…`). ## Looking after the private key > **Danger — It's the spending authority, and it can't be recovered:** > > Whoever has `agent-key.pem` can spend that wallet's balance, within your policies. Zanora never had the private key, so a lost key can't be reissued. The wallet keeps its balance but can never spend it. Treat the key like a wallet seed phrase. - Keep it in a file with mode `0600`, or in your secrets manager. In MCP configs, point to it with `ZANORA_AGENT_KEY_FILE` rather than pasting it in, because config files get shared and appear in screen-shares. - Only fund a wallet with what you'd accept losing to that agent. - If a key leaks: **freeze** the wallet (`POST /v1/wallets/:id/freeze`), create a new wallet with a new key, and move future funding there. ## Reading a wallet | Call | Returns | |---|---| | `GET /v1/wallets` | every wallet in your workspace | | `GET /v1/wallets/:id/balance` | `{ balanceMinor, availableMinor, currency }`. Available excludes money on hold | | `GET /v1/wallets/:id/ledger` | every entry, oldest first: funding, charges, refunds | | `POST /v1/wallets/:id/freeze` / `unfreeze` | stop or allow outgoing payments | Next: [fund the wallet](https://docs.zanora.dev/buyers/funding.md). --- # Funding a wallet > Top up by card or bank, or send USDC on Base to a deposit address. Money is credited when the payment rail confirms it. Ask the gateway which options it offers before you choose one. An agent **can't fund its own wallet**. Moving money in is always a person's action, in the console or through your own code on their behalf. ## Ask what's available first Funding options depend on which payment providers the gateway has connected. Ask instead of guessing: ```bash curl -s https://api.zanora.dev/v1/rail/providers -H "x-api-key: $WKEY" ``` ```json { "sandbox": false, "providers": [ { "id": "stripe", "name": "Stripe", "roles": ["funding"], "currencies": ["USD"], "rails": ["card", "ach"], "topUps": true, "depositAddresses": false, "faucet": false, "status": "active" }, { "id": "circle", "name": "Circle", "roles": ["funding", "payout"], "currencies": ["USDC"], "rails": ["usdc_base"], "topUps": false, "depositAddresses": true, "faucet": false, "status": "active" } ] } ``` | Flag | Means | Use | |---|---|---| | `topUps: true` | you can charge a card or bank account into the wallet | [Top-up](#top-up-card-or-bank) | | `depositAddresses: true` | you can get a USDC address for the wallet | [Deposit address](#usdc-deposit-address) | | `faucet: true` | testnet only: free test USDC | [Testnet faucet](#testnet-faucet) | | `sandbox: true` | a development gateway with mock rails | [Development shortcut](#development-shortcut) | ## Top-up (card or bank) The console's **Fund** button is the easiest route: it takes a card and handles confirmation. From the API: ```bash curl -s -X POST https://api.zanora.dev/v1/wallets/$WAL/topup -H "x-api-key: $WKEY" \ -H 'content-type: application/json' -d '{"amountMinor":"2000"}' # → {"intent":{"externalRef":"pi_…","asset":"USD","amountMinor":"2000","status":"requires_payment_method","hostedUrl":"…"}} ``` Finish the payment where the intent says: `hostedUrl` if present, or `clientSecret` for your own card form. The wallet is credited **when the card processor confirms**, not when this call returns. If the card is declined, `status` is `declined` with a `declineReason`. That's an answer, not an error. ## USDC deposit address ```bash curl -s -X POST https://api.zanora.dev/v1/wallets/$WAL/deposit-address -H "x-api-key: $WKEY" \ -H 'content-type: application/json' -d '{"asset":"USDC","chain":"base"}' # → {"address":{"id":"…","walletId":"wal_…","treasuryProviderId":"circle","chain":"base","asset":"USDC","address":"0x5f3a…","createdAt":"…"}} ``` - Calling it again returns **the same address**, never a second one. - Send **only USDC on Base** to it. Other tokens and chains aren't credited. - The wallet is credited in USD at 1:1 once the transfer is confirmed. - If USDC is trading more than **0.5% away from $1** when the deposit arrives, it's **held**, not credited at a guessed rate. Zanora checks held deposits again automatically, and credits them once the price is back within the band. `GET /v1/wallets/$WAL/deposit-addresses` lists a wallet's addresses. The `zanora_deposit_addresses` MCP tool reports them to an agent, so it can tell its user where to send money. ## Testnet faucet On a testnet deployment where a provider reports `faucet: true`: ```bash curl -s -X POST https://api.zanora.dev/v1/wallets/$WAL/faucet -H "x-api-key: $WKEY" ``` This sends real testnet USDC to the wallet's deposit address, and it's credited the same way as any deposit. ## Development shortcut Only on a local or sandbox gateway (`sandbox: true`): ```bash curl -s -X POST http://127.0.0.1:8080/v1/wallets/$WAL/fund -H "x-api-key: $WKEY" \ -H 'content-type: application/json' -d '{"amountMinor":"5000","rail":"card"}' ``` It credits instantly against a simulated deposit. Anywhere with a real payment provider connected, including the hosted platform, it returns `501 NOT_SUPPORTED`. ## Checking it landed ```bash curl -s https://api.zanora.dev/v1/wallets/$WAL/balance -H "x-api-key: $WKEY" curl -s https://api.zanora.dev/v1/rail/transactions -H "x-api-key: $WKEY" # each deposit and its state ``` A balance of zero right after you fund is normal. Card payments usually confirm within seconds and USDC within minutes. --- # Spending policies and budgets > Rules the gateway applies to every purchase before any money moves — deny, require approval, or allow-list — plus a daily budget for the whole workspace. A policy is a short text document attached to your workspace. The gateway evaluates it **on the server, before the payment is taken**, for every purchase by every wallet in the workspace. An agent can't skip it, and a model can't talk its way past it. ## The language ```yaml deny: category == "Adult" provider.reputation < 0.3 approval: price > 5 allow: provider.verified == true currency == "USD" ``` There are three kinds of section, and each line is `field comparison value`: | Section | A purchase is caught when… | Result | |---|---|---| | `deny:` | **any** line matches | `POLICY_DENIED`: refused | | `approval:` | **any** line matches | `APPROVAL_REQUIRED`: held for a person | | `allow:` | **all** lines in one allow policy match, for at least one allow policy | if allow policies exist and none matches: `POLICY_DENIED` | ### Fields | Field | Type | Example | |---|---|---| | `price` | number, **in dollars** | `price > 5` | | `category` | string | `category == "OCR"` | | `currency` | string | `currency == "USD"` | | `provider.verified` | boolean | `provider.verified == true` | | `provider.reputation` | number, 0 to 1 | `provider.reputation < 0.3` | | `provider.id` | string | `provider.id == "prv_3652ec9e…"` | | `capabilityId` | string | `capabilityId != "cap_…"` | | `agentId` | string | `agentId == "experiments"` | | `walletId` | string | `walletId == "wal_…"` | Comparisons: `==` `!=` `<` `<=` `>` `>=`. Put strings in quotes. `#` starts a comment. A line that refers to a field the purchase doesn't have never matches. ## Evaluation order 1. **Deny rules.** The first matching line refuses the purchase. 2. **The workspace daily budget.** If this purchase would take today's spending over the budget, it's refused. 3. **Approval rules.** The first matching line holds the purchase, unless a person has already approved this exact spend. See [Approvals](https://docs.zanora.dev/buyers/approvals.md). 4. **Allow rules.** If any exist, at least one must match completely. 5. Otherwise the purchase is **allowed**. ## Adding a policy In the console: **Buyer → Policies → Add a rule**, choose the section, and type one rule per line. With the API: ```bash curl -s -X POST https://api.zanora.dev/v1/policies -H "x-api-key: $WKEY" \ -H 'content-type: application/json' \ -d '{"document":"approval:\n price > 5\ndeny:\n provider.verified == false"}' ``` The response lists the parsed policies, each with its own `id`. A document with several sections becomes several policies. | Call | Does | |---|---| | `GET /v1/policies` | list your workspace's policies, including disabled ones | | `POST /v1/policies/:id/disable` | stop applying one, from the next purchase | | `POST /v1/policies/:id/enable` | start applying it again | Policies can't be edited. To change one, add the corrected version and then disable the old one. That keeps a record of which rules applied to which purchases. ## Daily budget The workspace's `dailyBudgetMinor`, set at signup, caps total spending across all its wallets per UTC day. When a purchase would exceed it, the purchase is refused with `POLICY_DENIED` and `matchedRule: "workspace.dailyBudget"`. ## Recipes ```yaml title="Human in the loop above $5" approval: price > 5 ``` ```yaml title="Only verified, well-rated sellers" allow: provider.verified == true provider.reputation >= 0.6 ``` ```yaml title="Sandbox an experimental agent" deny: agentId == "experiments" price > 0.25 ``` ```yaml title="Pin one seller" allow: provider.id == "prv_3652ec9e…" ``` > **Warning — The pause after repeated failures isn't a policy:** > > Separately from your rules, if one seller fails three calls in a row for a wallet (a 5xx, a crash, an MCP `isError` or a timeout, not a 4xx for a bad request), that wallet is paused from buying from that seller (`WALLET_PROVIDER_PAUSED`). This protects your balance from a broken seller. It lifts on its own, or you can [lift it now](https://docs.zanora.dev/api/wallets.md#post-v1-wallets-id-resume-provider). --- # Approvals > When a policy holds a purchase for a person, how they approve or deny it, how the agent finds out, and how to make sure a model can never approve its own spend. When an `approval:` rule matches, the purchase stops **before any money moves**. The agent gets `APPROVAL_REQUIRED` with an `approvalId`, and the gateway records an approval request: ```json { "id": "apr_7c1e…", "workspaceId": "wsp_…", "agentId": "invoice-bot", "walletId": "wal_91c2…", "capabilityId": "cap_7f3a…", "amountMinor": "1200", "currency": "USD", "rule": "price > 5", "status": "pending", "createdAt": "…" } ``` ## The flow ### Step 1: The agent is refused Nothing is charged. With the SDK, `invoke` throws `ZanoraPolicyRejection` with `code: "APPROVAL_REQUIRED"` and `approvalId`. With MCP, the tool result says the same in plain language. ### Step 2: A person decides In the console: **Buyer → Approvals** shows pending requests with the amount, capability, wallet and the rule that held it. Click approve or deny. With the API: ```bash curl -s "https://api.zanora.dev/v1/approvals?status=pending" -H "x-api-key: $WKEY" curl -s -X POST https://api.zanora.dev/v1/approvals/apr_7c1e…/approve -H "x-api-key: $WKEY" \ -H 'content-type: application/json' -d '{"resolvedBy":"dana@northwind.example"}' ``` ### Step 3: The agent retries once An approval covers **one** purchase by that wallet of that capability, at up to the approved amount. It's used up by the next matching purchase, and a third call is held again. The agent learns the decision with `zanora_approval_status` (MCP), or by polling `GET /v1/approvals`. ## Approving inside an MCP client If the client supports **MCP elicitation**, `@zanora/mcp` asks the user directly at the moment of the purchase, and retries once if they approve: ```text Approve a $12.00 USD purchase? Buying Invoice OCR Capability cap_7f3a… Wallet wal_91c2… Held by price > 5 [ Approve — spend $12.00 USD ] [ Deny — do not buy this ] ``` - The prompt is built from the gateway's approval record, **never** from the tool call. A model that assembled the purchase can't write the text the person approves. - **Deny** returns `APPROVAL_DENIED`, and the agent is told not to retry or ask again. - **Dismissing** the prompt leaves the request pending. Silence never counts as approval. - Clients without elicitation are never prompted. They get the approval id and stop. ## Keeping approval with people Recording an approval is an authenticated call, so it only succeeds if the agent's key has the `approvals:write` scope. A workspace **root key has it.** Two ways to stop a model approving its own spend: | Setting | Effect | |---|---| | Give the agent a key **without `approvals:write`** | the gateway refuses to record the answer (`APPROVAL_NOT_PERMITTED`). Enforced by the platform. **Recommended.** | | `ZANORA_APPROVAL_ELICITATION=off` | the MCP server never shows the prompt. Enforced only by that process's configuration | ```bash # An agent key that can buy and read, but not approve curl -s -X POST https://api.zanora.dev/v1/auth/keys -H "x-api-key: $ROOT_KEY" \ -H 'content-type: application/json' \ -d '{"label":"invoice-bot","scopes":["discovery:read","capabilities:read","wallets:read","receipts:read","approvals:read","ratings:write","rail:read"]}' ``` --- # Buy from an MCP client > Add @zanora/mcp to Claude, Cursor or any MCP client, and the model can discover, pay for and verify capabilities from its own wallet with no integration code. `@zanora/mcp` is an MCP server that runs locally over stdio. It gives the model ten tools. Two of them matter most: `zanora_discover` finds something to buy, and `zanora_invoke` pays for it and calls it. ## Add it to your client ```json title="MCP config" { "mcpServers": { "zanora": { "command": "npx", "args": ["-y", "@zanora/mcp"], "env": { "ZANORA_API_KEY": "zk.akey_…", "ZANORA_AGENT_KEY_FILE": "/absolute/path/to/agent-key.pem", "ZANORA_MAX_PRICE_MINOR": "500" } } } } ``` | Client | Where the config goes | |---|---| | Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS), `%APPDATA%\Claude\claude_desktop_config.json` (Windows) | | Claude Code | `.mcp.json` in the project, or `claude mcp add zanora -- npx -y @zanora/mcp` | | Cursor | `.cursor/mcp.json` | | Anything else | the client's MCP server config: command `npx`, args `["-y", "@zanora/mcp"]` | Restart the client. The server checks the configuration at startup and prints a banner to stderr. A misconfiguration fails immediately with the name of the variable at fault. > **Note — You need a wallet first:** > > The config uses a workspace API key and the private key of an agent wallet. If you don't have them, follow [Create a workspace](https://docs.zanora.dev/buyers/signup.md) and [Wallets and agent keys](https://docs.zanora.dev/buyers/wallets.md), then [fund the wallet](https://docs.zanora.dev/buyers/funding.md). ## Configuration | Variable | Required | Meaning | |---|---|---| | `ZANORA_API_KEY` | **yes** | a workspace key. Use a dedicated agent key rather than the root one (see [Approvals](https://docs.zanora.dev/buyers/approvals.md#keeping-approval-with-people)) | | `ZANORA_AGENT_KEY_FILE` | yes¹ | path to the agent's ed25519 private key PEM | | `ZANORA_AGENT_KEY` | yes¹ | the PEM inline, if you can't use a file | | `ZANORA_WALLET_ID` | no | only needed when the workspace has more than one agent wallet. With several and none named, it refuses to start and lists them | | `ZANORA_MAX_PRICE_MINOR` | no | a per-call ceiling in cents. **Set one.** A tool argument can lower it for one call but never raise it | | `ZANORA_GATEWAY_URL` | no | defaults to `https://api.zanora.dev` | | `ZANORA_AGENT_ID` | no | the agent name on payment proofs (defaults to the wallet id) | | `ZANORA_APPROVAL_ELICITATION` | no | `on` (default) or `off`: whether to ask the user about held spends | | `ZANORA_ALLOW_LOCAL_PACKAGES` | no | packages this agent may **run** to buy them. Unset means none. See [Running seller packages](https://docs.zanora.dev/buyers/local-packages.md) | | `ZANORA_SELLER_HEADERS` | no | JSON keyed by seller origin, for sellers behind their own auth: `{"https://seller.example":{"authorization":"Bearer …"}}` | ¹ One of the two. ## The tools | Tool | Does | Spends | |---|---|:-:| | `zanora_discover` | ranked marketplace search: price, seller reputation, request schema | | | `zanora_capability` | resolves a known capability id (from an earlier session, a receipt, or the user) so it can be invoked | | | `zanora_invoke` | pays a capability's price and returns its response and signed receipt | **✓** | | `zanora_wallet_balance` | balance and available balance | | | `zanora_wallet_ledger` | every charge, refund and deposit | | | `zanora_purchases` | what this wallet bought, from its receipts. Pages with `after` | | | `zanora_receipt` | fetches a receipt and checks its signature | | | `zanora_rate_provider` | rates a seller 1–5 for a purchase this wallet made | | | `zanora_deposit_addresses` | where money can be sent to this wallet | | | `zanora_approval_status` | whether a person approved a held spend | | The loop is **discover → invoke with the `capabilityId` from a result**. The server remembers endpoints from discovery, so the model never types a URL. An id it hasn't seen is an error, not a guess, and `zanora_capability` resolves it properly. REST and MCP capabilities are both bought with `zanora_invoke`. Every tool returns typed `structuredContent` along with text. `zanora_invoke` reports four named progress steps if the client sends a progress token. ## When the answer is "no" Refusals come back as readable tool results, and nothing is charged: | Result | The model should | |---|---| | `POLICY_DENIED` | pick another capability. Never retry the same one | | `APPROVAL_REQUIRED` + id | report the id and stop. If the client supports it, the user is asked directly (see [Approvals](https://docs.zanora.dev/buyers/approvals.md)) | | `APPROVAL_DENIED` | stop. Don't ask again | | `INSUFFICIENT_FUNDS` | stop and tell the user where to send money (`zanora_deposit_addresses`). With elicitation, the user is shown the shortfall and the addresses, and the purchase is retried once the balance has actually changed | | `PRICE_ABOVE_CLIENT_LIMIT` | the price is over `ZANORA_MAX_PRICE_MINOR`. Choose something cheaper | ## Resources The server also offers resources a person can browse without asking the model: `zanora://wallet` (balance) and `zanora://purchases` (recent receipts). They return the same data as the matching tools. ## Try it Ask the model: ```text Search Zanora for invoice OCR under $2, buy the cheapest one on https://example.com/invoice.png, verify the receipt, and rate the seller. ``` ## Safety model - **The key file is the spending authority.** Give each agent its own wallet, funded with what you'd accept losing to it. - **The limits are on the server.** Budgets and policies are checked by the platform. `ZANORA_MAX_PRICE_MINOR` is an extra limit on your side, not a replacement. - **Buying and reading only.** The server can't create wallets, move money between them, change policies or read another tenant's data. - **Running code is off by default.** A search result can never cause a seller's code to run on your machine. --- # Buy from code with the SDK > @zanora/sdk in TypeScript: discover capabilities, pay for REST and MCP ones with the same call, verify receipts, handle refusals, and cancel safely. ```bash npm install @zanora/sdk ``` ## Create the agent ```ts import { readFileSync } from "node:fs"; import { ZanoraAgent } from "@zanora/sdk"; const agent = new ZanoraAgent({ apiKey: process.env.ZANORA_API_KEY!, // workspace key: authenticates API calls walletId: "wal_f33b5618…", // the agent's wallet agentId: "invoice-bot", // appears on payment proofs privateKeyPem: readFileSync("agent-key.pem", "utf8"), // signs payment proofs maxPriceMinor: 500n, // refuse to sign above $5.00 }); ``` | Option | Required | Meaning | |---|---|---| | `apiKey` | yes, on any real deployment | the workspace key. Without it, calls fail with `invalid api key` | | `walletId` | yes | the agent wallet to spend from | | `agentId` | yes | a plain label for this agent | | `privateKeyPem` | yes | the ed25519 private key whose public half the wallet holds | | `maxPriceMinor` | no | a `bigint` ceiling. The client refuses to sign a proof above it (`PRICE_ABOVE_CLIENT_LIMIT`) | | `mcpCaller` | for MCP capabilities | how to reach MCP servers. See [below](#buying-mcp-capabilities) | | `gatewayUrl` | no | defaults to `https://api.zanora.dev`. Set it only for a sandbox or a self-hosted gateway | ## Discover ```ts const { results } = await agent.discover({ query: "extract text from invoices", category: "OCR", // optional filters protocol: "rest", maxPriceMinor: "200", // a string, in cents maxLatencyMs: 3000, limit: 5, }); for (const r of results) { console.log(r.capability.name, r.capability.priceMinor, r.provider.name, r.provider.verified, r.score); } ``` Results are ranked, best first. Each has `capability` (including `schema`, the request body's contract), `version` (the address), `provider` and `score`. ## Buy a REST capability ```ts const result = await agent.invoke<{ text: string }>(best.version.endpoint, { body: { imageUrl: "https://example.com/invoice.png" }, // sent as JSON // method: "POST" (default), headers: {...}, signal: AbortSignal }); result.data; // the seller's response, typed result.receipt; // the signed receipt result.receiptVerified; // Zanora signed it result.responseHashVerified; // …and it's for this exact response result.transactionId; ``` `invoke` handles the whole handshake: it calls the endpoint, receives the `402` challenge, checks the challenge's signature, signs a proof, retries, and verifies the receipt. ## Buying MCP capabilities MCP capabilities need an MCP transport. It's injected, so the SDK itself depends only on `@zanora/core`: ```bash npm install @zanora/mcp ``` ```ts import { resolveCapabilityAddress } from "@zanora/core"; import { StreamableHttpMcpToolCaller } from "@zanora/mcp"; const agent = new ZanoraAgent({ /* …as above… */ mcpCaller: new StreamableHttpMcpToolCaller() }); const { results } = await agent.discover({ query: "invoice OCR", protocol: "mcp" }); const best = results[0]!; const result = await agent.invokeMcp(resolveCapabilityAddress(best.version), { tool: best.capability.toolName!, arguments: { imageUrl: "https://example.com/invoice.png" }, }); result.mcpResult; // the seller's MCP result, unmodified: content blocks, structuredContent, _meta ``` To buy across both protocols, branch on `capability.protocol`. Nothing else changes: ```ts const r = best.capability.protocol === "mcp" ? await agent.invokeMcp(resolveCapabilityAddress(best.version), { tool: best.capability.toolName!, arguments: args }) : await agent.invoke(best.version.endpoint, { body: args }); ``` > **Warning — Use `resolveCapabilityAddress`, not `endpoint`, for MCP:** > > Some MCP capabilities are packages the buyer runs rather than URLs it calls. `resolveCapabilityAddress(version)` returns the correct kind. Running packages needs its own opt-in. See [Running seller packages](https://docs.zanora.dev/buyers/local-packages.md). ## Handling refusals ```ts import { ZanoraPaymentError, ZanoraPolicyRejection } from "@zanora/sdk"; try { const r = await agent.invoke(url, { body }); } catch (err) { if (err instanceof ZanoraPolicyRejection) { // The gateway refused the payment. Nothing was charged. // err.code: "POLICY_DENIED" | "APPROVAL_REQUIRED" | "INSUFFICIENT_FUNDS" | "WALLET_FROZEN" | … // err.decision?.matchedRule, err.approvalId } else if (err instanceof ZanoraPaymentError) { // Your client refused before paying. Nothing was charged. // err.code: "PRICE_ABOVE_CLIENT_LIMIT" | "CANCELLED" | "INVALID_CHALLENGE" | "MCP_TRANSPORT_UNAVAILABLE" } else { // e.g. TypeError("fetch failed"): the seller's URL wasn't reachable. The call never got as far as a price. throw err; } } ``` `INVALID_CHALLENGE` means the price quote wasn't signed by Zanora's key. Don't pay it. A seller failure is **not** an exception. You get a result whose `receipt.status` is `"failed"`, and the money has already been refunded. See [Errors and refusals](https://docs.zanora.dev/concepts/errors.md) for every code. ## Cancelling Pass an `AbortSignal`. It works **until the payment proof is sent**. After that, the call completes on purpose: the money has moved and the seller is working, and dropping the connection would only lose an answer you paid for. ```ts const ac = new AbortController(); setTimeout(() => ac.abort(), 2000); await agent.invoke(url, { body, signal: ac.signal }); // → ZanoraPaymentError("CANCELLED"): nothing was charged. The only code that guarantees that. ``` ## After the purchase ```ts await agent.rateProvider(best.provider.id, result.receipt!.transactionId, 5); // 1–5, once per purchase await agent.verifyReceipt(result.receipt!); // re-check any receipt, including an old one await agent.balance(); // { balanceMinor, availableMinor, currency } ``` Rate what you buy. Discovery ranks on reputation, and only a wallet holding the receipt can rate. ## `ZanoraClient` `ZanoraClient` (same package) wraps the other API routes with types: wallets, policies, approvals, receipts. Use it for setup scripts. The full route list is in the [API reference](https://docs.zanora.dev/api/overview.md). --- # Use one seller's tools directly > @zanora/mcp-proxy puts one paid MCP server in front of your agent. Its tools appear under their own names with their own schemas, and each call is paid for as it's made. With [`@zanora/mcp`](https://docs.zanora.dev/buyers/mcp.md), the model decides to buy. It calls `zanora_invoke` with a capability id. With the proxy, a paid tool is just a tool. The model calls `extract_invoice(imageUrl)`, and payment happens during the call. | | `@zanora/mcp` | `@zanora/mcp-proxy` | |---|---|---| | The model calls | `zanora_invoke(capabilityId, body)` | `extract_invoice(imageUrl)` | | Finding a seller | `zanora_discover` searches the marketplace | you name one seller in the config | | Paying | a tool the model chooses to call | happens during the tool call | | Best for | an agent that shops across many sellers | an agent that uses a seller you've already chosen | You can use both. The model can shop, and also have your chosen seller's tools to hand. ## Add it ```json title="MCP config" { "mcpServers": { "acme-ocr": { "command": "npx", "args": ["-y", "@zanora/mcp-proxy", "https://demo-seller.zanora.dev/mcp"], "env": { "ZANORA_API_KEY": "zk.akey_…", "ZANORA_AGENT_KEY_FILE": "/absolute/path/to/agent-key.pem", "ZANORA_MAX_PRICE_MINOR": "500" } } } } ``` The model now sees the seller's tools, with the price added to each description: ```text extract_invoice(imageUrl) Extract text and structured line items from scanned invoices. Costs $1.00 USD per call, charged to this agent's wallet. ``` ## Configuration The same variables as `@zanora/mcp`, so one agent running both sets them once: | Variable | Required | Meaning | |---|---|---| | `ZANORA_AGENT_KEY_FILE` / `ZANORA_AGENT_KEY` | yes (one of) | the agent wallet's private key | | `ZANORA_API_KEY` | yes on any real deployment | workspace key | | `ZANORA_MAX_PRICE_MINOR` | no, but **set it** | per-call ceiling in cents. Without it, the proxy pays whatever a tool costs, up to the wallet's balance. The seller sets its own prices, so this is the limit you control | | `ZANORA_WALLET_ID` | no | only when the workspace has several agent wallets | | `ZANORA_GATEWAY_URL` | no | defaults to `https://api.zanora.dev` | | `ZANORA_SELLER_HEADERS` | no | JSON keyed by origin, for a seller behind its own auth | | `ZANORA_TOOLS_POLL_MS` | no | how often to re-read the seller's tool list. Default 60000, minimum 15000, `0` to turn off | The seller URL is the argument. Use one proxy entry per seller, so two sellers' tool names can't collide. ## Behaviour to know - **The seller's result is passed through unchanged**: content blocks, `structuredContent`, the seller's `_meta`. You get exactly what you paid for. - **It only calls URLs.** It won't run a seller's package. That needs a separate opt-in in `@zanora/mcp`. - **It refuses plain `http://` sellers**, except loopback for local development. The answer you paid for comes back over that connection. - **It keeps the tool list current.** It declares `tools/listChanged`, and notices price changes both from each purchase's receipt and from a slow poll. It never announces a change on a guess: a seller that's briefly unreachable hasn't changed its list. - **Refusals come back as readable results** (`POLICY_DENIED`, `APPROVAL_REQUIRED`, `INSUFFICIENT_FUNDS`), not protocol errors, and nothing is charged. - **Cancelling** stops the purchase only until the payment proof is sent, the same as the SDK. > **Note — Not yet in the proxy:** > > An `APPROVAL_REQUIRED` here reports the approval id and stops. The proxy doesn't prompt the user through elicitation the way `@zanora/mcp` does yet, and doesn't prompt about funding either. --- # Running seller packages > Some MCP capabilities are a pinned npm package that you run locally, rather than a server you call. This is off by default. Here's how to allow a specific package, and what protects you when you do. A seller whose MCP server only speaks stdio can still sell it: they publish an **exact version** of an npm package, and buyers run it the way MCP clients already run `npx -y …`. Discovery marks these capabilities `runsLocally: true`, and their address is `{ "kind": "package", "command": "npx", "args": [...], "version": "1.4.2" }`. Payment, receipts, refunds and policies work exactly as for a hosted capability. The difference is that **the seller's code runs on your machine.** ## It's off unless you allow it Nothing runs by default. A search result can never cause code to execute. You allow packages **by name**: ```json title="@zanora/mcp" "env": { "ZANORA_ALLOW_LOCAL_PACKAGES": "@acme/ocr-mcp,@acme/tts@2.1.0" } ``` ```ts title="@zanora/sdk" import { CompositeMcpToolCaller, LocalSpawnMcpToolCaller, StreamableHttpMcpToolCaller } from "@zanora/mcp"; const mcpCaller = new CompositeMcpToolCaller( new StreamableHttpMcpToolCaller(), new LocalSpawnMcpToolCaller({ policy: { allow: ["@acme/ocr-mcp"], // or "@acme/ocr-mcp@1.4.2" to allow one version only env: { ACME_REGION: "eu" }, // passed to the package explicitly, never inherited timeoutMs: 60_000, }, }), ); const agent = new ZanoraAgent({ /* … */ mcpCaller }); ``` ## What protects you - **Allowed per package.** Allowing `@acme/ocr-mcp` allows nothing else. Add `@version` to allow one exact version only. - **Exact versions.** A seller can only publish an exact version, never a range, and the pin is checked again before the process starts. The code that was published is the code that runs. - **A clean environment.** The package gets only the variables you pass in `env`. It never inherits your shell's secrets. - **Approved launchers only.** Package addresses must use an approved launcher such as `npx`. If a purchase is refused because the package isn't allowed, that's this setting working. An agent should report it to its user and not try to get around it. --- # Purchases, ledger and ratings > What your agents bought, what moved in each wallet, and how to rate sellers so discovery keeps ranking good ones first. There are two records, and they answer different questions: | Question | Record | Where | |---|---|---| | **What did we buy?** | receipts: capability, seller, amount, status, response hash | `GET /v1/receipts`, `zanora_purchases`, **Buyer → Wallets** in the console | | **What money moved?** | ledger entries: every funding, charge and refund | `GET /v1/wallets/:id/ledger`, `zanora_wallet_ledger` | Ledger entries carry opaque references only, not capability names, so "what was that $1.00 for?" is answered by the receipt. ## Purchases ```bash curl -s "https://api.zanora.dev/v1/receipts?limit=50" -H "x-api-key: $WKEY" # → {"receipts":[…], "nextCursor":"rcp_…"} (nextCursor only when the page was full) curl -s "https://api.zanora.dev/v1/receipts?limit=50&after=rcp_…" -H "x-api-key: $WKEY" ``` A workspace key sees purchases across all of its wallets, newest first. Page through with `after`, and stop when there's no `nextCursor`. ## Ledger ```bash curl -s https://api.zanora.dev/v1/wallets/$WAL/ledger -H "x-api-key: $WKEY" ``` A charge that was later refunded appears **twice**: the debit, then an offsetting credit. Nothing is ever removed. ## Rate what you buy Discovery ranks sellers partly on reputation, which comes only from buyers. A rating needs: - a **receipt** for the transaction, held by **your wallet**; - from **that seller**; - **once** per transaction, 1 to 5. ```ts title="SDK" await agent.rateProvider(receipt.providerId, receipt.transactionId, 4); ``` ```bash title="API" curl -s -X POST https://api.zanora.dev/v1/providers/$PROVIDER_ID/rate -H "x-api-key: $WKEY" \ -H 'content-type: application/json' \ -d '{"transactionId":"txn_…","rating":4,"raterWalletId":"wal_…"}' ``` ```text title="MCP" zanora_rate_provider(providerId: "prv_…", transactionId: "txn_…", rating: 4) ``` Failed and refunded calls can be rated too, on purpose: a buyer whose call broke has the best reason to leave a low score. | Response | Means | |---|---| | `404` | no receipt for that transaction | | `403` | not your wallet, not your purchase, or the wrong seller | | `400` | already rated, or the rating isn't a whole number from 1 to 5 | --- # Buyer troubleshooting > The problems buyers run into most, what causes each one, and the fix. Start with `GET /v1/whoami`. It shows whether your key works and what it's allowed to do. #### Discovery returns {"results":[]} - Your filters are too narrow: try without `maxPriceMinor`, `category` or `protocol`. - The seller you're looking for **isn't verified yet**. Unverified sellers don't appear in discovery. If you know their capability id, `GET /v1/capabilities/:id` still describes it, but without an address to call. - Check the capability exists at all: the [demo seller](https://docs.zanora.dev/environments.md) always appears for `query: "invoice"`. #### "invalid api key" / 401 on every call - `ZANORA_API_KEY` is missing (MCP) or `apiKey` wasn't passed (SDK). The wallet's private key signs payments, but API calls still need the workspace key. - The key was revoked, expired, or rotated past its grace period. Mint a new one in **Settings**. #### INVOKE_FAILED — fetch failed (balance unchanged) The seller's URL isn't reachable from where your agent runs, usually because it's a `localhost` address published from someone's laptop. Nothing was charged: the call failed before the `402`. Tell the seller, or pick another capability. #### POLICY_DENIED when you expected ALLOW - `matchedRule` in the error says which rule matched. `workspace.dailyBudget` means the daily cap was reached. `allow` means allow rules exist and none matched. - Remember `price` in rules is in **dollars**: `price > 5` means more than $5.00. - `GET /v1/policies` lists what's actually active. #### APPROVAL_REQUIRED again after approving An approval covers **one** purchase at up to the approved amount. The retry uses it up, and the next call is held again. To stop being asked for that kind of spend, change the rule. #### INSUFFICIENT_FUNDS right after funding Funding is credited when the rail **confirms**, not when you submit it. `GET /v1/rail/transactions` shows the deposit's state. A USDC deposit made while USDC was more than 0.5% away from $1 is **held** and credited later. #### WALLET_PROVIDER_PAUSED That seller failed three charged calls in a row for this wallet, so calls between them are paused (and you were refunded for each failure). Only the seller's own failures count: a 5xx, a crash, an MCP `isError` or a timeout. A 4xx for a bad request is refunded but never pauses you. Wait for the pause to lift, or `POST /v1/wallets/:id/resume-provider` with `{"providerId":"prv_…"}`. #### "ZANORA_WALLET_ID is required" / the MCP server lists several wallets Your workspace has more than one agent wallet, and the server won't guess which one should spend. Set `ZANORA_WALLET_ID`. #### responseHashVerified is false The receipt is genuine, but it's for a different response than the one you received. Something between you and the seller changed the answer, or the seller returned something other than what it reported to Zanora. Keep the data if it's useful, but don't treat the receipt as proof of it. #### Buying an MCP capability throws MCP_TRANSPORT_UNAVAILABLE The SDK needs an MCP transport: `new ZanoraAgent({ …, mcpCaller: new StreamableHttpMcpToolCaller() })` from `@zanora/mcp`. #### A runsLocally capability is refused Running seller packages is off unless you allow the package by name in `ZANORA_ALLOW_LOCAL_PACKAGES`. See [Running seller packages](https://docs.zanora.dev/buyers/local-packages.md). ## Still stuck? Email [support@zanora.dev](mailto:support@zanora.dev) with your `wsp_…` id and the `errorId` from any `500` response, or the `approvalId` / `transactionId` involved. Never send an API key or a private key. --- # Seller overview > What selling on Zanora involves, what you never have to build, and which integration fits your server — an MCP tool, an HTTP endpoint, or a package buyers run themselves. As a seller (a **provider**, `prv_…`), you put a per-call price on something you already run. Zanora handles the parts you'd otherwise build yourself: accounts, API keys for buyers, billing, invoicing, collections, refunds and fraud checks on the payer. You add one line of middleware, and your handler only runs once a call has been paid for. ## What you never handle - **Buyer identities or card numbers.** A paid call arrives with an opaque transaction id, wallet id and agent id. - **Pricing on the request path.** Zanora signs the price quote from your listing, so buyers can't haggle and your server can't misquote. - **Refunds.** If your handler throws or returns an error, the buyer is refunded automatically and you aren't paid for that call. - **Collections.** Buyers pay from pre-funded wallets, and each sale settles to your settlement wallet moments after the call. ## The seller lifecycle ### Step 1: Sign up Get a provider account, a settlement wallet and a provider API key. See [Create a provider account](https://docs.zanora.dev/sellers/signup.md). ### Step 2: Ask to be verified Your capabilities are **hidden from discovery until a Zanora operator verifies you.** Ask straight away, because it runs alongside everything else. See [Getting verified](https://docs.zanora.dev/sellers/verification.md). ### Step 3: Publish and serve Describe what you sell, set a price, and put the Zanora middleware in front of it. For MCP, one call does both. ### Step 4: Get paid Every sale settles to your wallet, less the platform fee (3% by default). Pay out to a bank account or USDC. See [Earnings and payouts](https://docs.zanora.dev/sellers/payouts.md). ## Pick your integration - [Sell an MCP tool](https://docs.zanora.dev/sellers/mcp.md): `zanoraSeller().sell({ tool, price, url })` publishes the tool and returns a wrapper for its handler. There's no capability id in your code, and restarting keeps the listing in line with your source. - [Sell an HTTP endpoint](https://docs.zanora.dev/sellers/rest.md): Publish a capability, then add `zanora({ backend, capabilityId })` to the Express route. Other frameworks call the same four gateway routes. - [Sell without hosting](https://docs.zanora.dev/sellers/packages.md): **Preview.** A stdio MCP server with nowhere to run it can be sold as a pinned npm package that buyers run. Read the credentials note first. - [No code at all](https://docs.zanora.dev/sellers/no-code.md): Publish, edit, request verification and watch sales from an MCP client using `@zanora/mcp` in seller mode. ## Three things that catch sellers out > **Warning — Read these before you publish:** > > 1. **Unverified means invisible.** Discovery hides unverified sellers completely. If you published correctly and buyers see `{"results":[]}`, this is why. > 2. **Your URL must be reachable from the buyer's agent,** not just from Zanora. Zanora never calls you: the buyer's agent does. A `localhost` URL can't be bought by anyone else. Use a public host, or a tunnel while testing. > 3. **Publishing is advertising; the middleware is what charges.** If you publish a URL that isn't behind the Zanora middleware, it just serves calls for free. --- # Seller quickstart > Sell an MCP tool on the hosted platform. Sign up, ask for verification, publish and serve with one call, make a test purchase, and see the sale land. This walkthrough sells one MCP tool for $0.10 a call. For an HTTP endpoint, the steps are the same except step 3. See [Sell an HTTP endpoint](https://docs.zanora.dev/sellers/rest.md). Steps marked 👤 need a person. ### Step 1: Create a provider account 👤 At [console.zanora.dev](https://console.zanora.dev/#signup), choose **Seller**, enter your email, organisation and password, and click the emailed link. The console shows your **provider API key once**. Save it: ```bash export ZANORA_API_KEY='zk.akey_….…' curl -s https://api.zanora.dev/v1/whoami -H "x-api-key: $ZANORA_API_KEY" # → "kind":"provider","providerId":"prv_…" ``` ### Step 2: Ask to be verified In the console, **Seller → Publish** shows a banner with a **Request verification** button. Or: ```bash curl -s -X POST https://api.zanora.dev/v1/providers/prv_…/verification-request \ -H "x-api-key: $ZANORA_API_KEY" -H 'content-type: application/json' \ -d '{"note":"Market data API, live since 2024, ~40k calls/month","contact":"ops@acme.example"}' ``` You can carry on while you wait. Your listing exists, but buyers won't find it until an operator approves you. ### Step 3: Publish and serve in one call ```bash npm install @zanora/middleware-mcp @modelcontextprotocol/sdk zod express ``` ```ts title="server.ts" import express from "express"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { zanoraSeller } from "@zanora/middleware-mcp"; import { z } from "zod"; // Reads ZANORA_API_KEY (a provider key). The gateway defaults to https://api.zanora.dev. const seller = zanoraSeller(); const paid = await seller.sell({ tool: "quote", description: "Latest price for a stock ticker", category: "finance", price: "$0.10", url: "https://acme.example/mcp", // where buyers reach this server; or set ZANORA_PUBLIC_URL }); const app = express().use(express.json()); app.post("/mcp", async (req, res) => { const server = new McpServer({ name: "acme-quotes", version: "1.0.0" }); server.tool("quote", "Latest price for a ticker. Costs $0.10.", { ticker: z.string() }, paid(async ({ ticker }) => { const answer = { ticker: String(ticker), price: await lookup(String(ticker)) }; // structuredContent is what the receipt's hash covers, so it's what the buyer can prove they received. return { content: [{ type: "text", text: JSON.stringify(answer) }], structuredContent: answer }; }), ); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on("close", () => void transport.close()); await server.connect(transport); await transport.handleRequest(req, res, req.body); }); app.listen(4025); async function lookup(ticker: string) { return 187.42; } ``` The first start **publishes** the capability. Later starts **reconcile** it: if you've changed the price or description in code, the listing is updated to match. A call without payment gets back a signed price quote, and your handler runs only once the call is paid for. > **Warning — The URL must be public:** > > Buyers' agents call `https://acme.example/mcp` themselves. While you develop, put the port behind a tunnel (for example `ngrok http 4025`) and publish that URL instead. ### Step 4: Make a test purchase Once you're verified, buy from yourself with any buyer setup, for example the [buyer quickstart](https://docs.zanora.dev/quickstart.md): ```text Use zanora_discover to find "stock price" tools, then buy a quote for AAPL. ``` Before verification, you can still check the handshake. A call without payment should come back as a `PAYMENT_REQUIRED` result carrying the challenge. ### Step 5: Watch the sale land **Seller → Sales** lists every paid call with its receipt. **Seller → Earnings** shows your balance. The 3% fee rounds down to the cent, so at $0.10 it rounds to zero and you keep all `"10"`. At $1.00 you would keep `"97"`. Or from the API: ```bash curl -s "https://api.zanora.dev/v1/receipts?limit=10" -H "x-api-key: $ZANORA_API_KEY" ``` ### Step 6: Set a payout destination and cash out 👤 In **Seller → Earnings**, save a bank account id (`ext_…`) or a USDC address, then request a payout. See [Earnings and payouts](https://docs.zanora.dev/sellers/payouts.md). > **Tip — Runnable versions:** > > The platform repository has complete starters: `starters/c-seller-mcp` (this page) and `starters/b-seller-rest` (HTTP), each with a `--request-verification` flag that does step 2. --- # Create a provider account > Sign up as a seller, confirm your email, and receive your provider id, settlement wallet and API key. ## From the console At [console.zanora.dev](https://console.zanora.dev/#signup), choose **Seller**, enter your email, organisation name and a password, then click the emailed link. The console shows your **provider API key once**. Copy it into a password manager before you leave the page. ## From the API ```bash curl -s -X POST https://api.zanora.dev/v1/signup -H 'content-type: application/json' -d '{ "role": "seller", "email": "ops@acme.example", "organizationName": "Acme OCR", "website": "https://acme.example", "password": "optional — enables console sign-in" }' # → 202 {"signupId":"sgn_…","status":"pending","expiresAt":"…","emailSent":true} ``` Nothing is created until the emailed link is opened, or the token from it is sent to `POST /v1/signup/verify`: ```json { "status": "completed", "role": "seller", "tenant": { "kind": "provider", "id": "prv_3652ec9e…", "name": "Acme OCR", "walletId": "wal_546563d4…" }, "credential": { "keyId": "akey_…", "token": "zk.akey_….…", "scopes": ["capabilities:write", "payouts:write", "…"] } } ``` | You get | What it is | |---|---| | `tenant.id` (`prv_…`) | your provider id. Routes take it from your key, so you rarely type it | | `tenant.walletId` | your **settlement wallet**: every sale lands here, net of the fee | | `credential.token` | your root provider key, shown **once** | > **Tip — Include your website:** > > Operators look at it when deciding on [verification](https://docs.zanora.dev/sellers/verification.md). A real, working site is the easiest thing to approve. ## Keys for your servers Mint a narrower key for each deployment rather than shipping the root key: ```bash curl -s -X POST https://api.zanora.dev/v1/auth/keys -H "x-api-key: $ROOT_KEY" \ -H 'content-type: application/json' \ -d '{"label":"prod-mcp-server","scopes":["capabilities:read","capabilities:write","payments:write","receipts:read"],"expiresInSeconds":7776000}' ``` A server using `zanoraSeller().sell()` needs `capabilities:write` (to publish and reconcile) and `payments:write` (for the payment routes). A server using only the Express middleware needs just `payments:write`. Keep `payouts:write` off anything that runs unattended. See [Accounts and API keys](https://docs.zanora.dev/concepts/accounts-and-keys.md). Next: [ask to be verified](https://docs.zanora.dev/sellers/verification.md). --- # Getting verified > Your capabilities stay out of discovery until a Zanora operator verifies you. How to ask, what the states mean, and what to do after a denial. > **Warning — Unverified means invisible:** > > You can publish, price and edit before you're verified, but **buyers can't find anything you sell.** Discovery excludes unverified sellers completely, and a buyer searching sees `{"results":[]}` with no explanation. Verification also gives you a ranking boost. Verification is a person at Zanora checking that you're a real business. You **ask**, an operator **decides**, and you can't verify yourself. ## Ask ```bash title="API" curl -s -X POST https://api.zanora.dev/v1/providers/$PROVIDER_ID/verification-request \ -H "x-api-key: $ZANORA_API_KEY" -H 'content-type: application/json' \ -d '{"note":"OCR API, live since 2024, ~40k calls/month. Docs: https://acme.example/docs","contact":"ops@acme.example"}' ``` ```text title="Console" Seller → Publish → "Request verification" ``` ```text title="MCP (seller profile)" zanora_request_verification(note: "…", contact: "…") ``` The `note` is your case, read by an operator, so say what you sell, how long you've run it, and where they can see it. Both fields are optional, but a request with no context is harder to approve. ## Check where you stand ```bash curl -s https://api.zanora.dev/v1/providers/$PROVIDER_ID -H "x-api-key: $ZANORA_API_KEY" ``` | `verification.status` | Meaning | Your next step | |---|---|---| | *(no `verification` field)* | you've never asked | ask | | `requested` | you're in the queue, oldest first | wait. Asking again with a new note updates your request and keeps your place | | `approved` | `verified: true` and `status: "active"`. You're in discovery | nothing | | `denied`, with a `reason` | an operator said no, and why | fix what the reason says, then **ask again** | A new seller's `status` is `pending_verification` until approved. Asking again after a denial is the intended route. It starts a new request, and the old verdict doesn't carry over. From an MCP client, `zanora_provider_status` reports the same states. ## Testing before you're verified You can still check your integration end to end: - A call to your endpoint without payment should return `402` (HTTP) or a `PAYMENT_REQUIRED` result (MCP) with a signed challenge. - Buyers who have your **capability id** can see it with `GET /v1/capabilities/:id`, but won't get an address to call until you're verified. --- # Sell an MCP tool > @zanora/middleware-mcp: declare a price, get a wrapper for the handler. Publishing, reconciling on restart, pricing a whole server, showing prices in tools/list, and low-level servers. ```bash npm install @zanora/middleware-mcp @modelcontextprotocol/sdk ``` ## `sell()`: publish and wrap in one call ```ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { zanoraSeller } from "@zanora/middleware-mcp"; import { z } from "zod"; const seller = zanoraSeller(); // ZANORA_API_KEY (a provider key); gateway defaults to api.zanora.dev const paid = await seller.sell({ tool: "financial_analysis", description: "Advanced AI-powered financial analysis", category: "finance", price: "$0.10", url: "https://premium-api.example/mcp", // or set ZANORA_PUBLIC_URL schema: { type: "object", properties: { ticker: { type: "string" } }, required: ["ticker"] }, }); const server = new McpServer({ name: "premium-api", version: "1.0.0" }); // Paid tool: wrap the handler. server.tool("financial_analysis", "Advanced financial analysis. Costs $0.10.", { ticker: z.string() }, paid(async (args) => ({ content: [{ type: "text", text: await analyse(String(args.ticker)) }] })), ); // Free tool: no wrapper. server.tool("ping", "Health check", {}, async () => ({ content: [{ type: "text", text: "pong" }] })); ``` That's the whole integration. There's no billing code, and no capability id in your source. ### `sell()` options | Option | Required | Meaning | |---|---|---| | `tool` | yes | the MCP tool name. One capability per tool | | `description` | yes | what it does, in the words a buyer would search for. Discovery ranks on it | | `category` | yes | what buyers filter on, e.g. `"OCR"` or `"finance"` | | `price` **or** `priceMinor` | yes | `"$0.10"` (dollars, with the `$`) or `"10"` (cents). A plain number or `price: "10"` is refused as ambiguous | | `url` | one of these | the URL buyers' agents call. Defaults to `ZANORA_PUBLIC_URL` | | `runsLocally` | one of these | sell as a package buyers run: `true` reads name and version from your `package.json`, or pass `"@acme/ocr-mcp@1.4.2"`. See [Sell without hosting](https://docs.zanora.dev/sellers/packages.md) | | `name` | no | the display name in discovery. Defaults to the tool name | | `schema` | no, but **recommended** | JSON Schema for the arguments. Buyers' agents read it before paying | | `tags`, `latencyP50Ms`, `currency` | no | extra discovery signals | ## What the wrapper does 1. A call **without payment** returns an error result carrying a **signed challenge**, with the price quoted by Zanora, not by you. 2. The buyer signs a payment proof and calls again with it in `_meta`. 3. The proof is checked on the server: signature, replay, the buyer's policy and budget, their balance. **Then** your handler runs. 4. Your result is hashed into a **signed receipt** that goes back in the result's `_meta`. 5. If your handler **throws or returns `isError: true`**, the call fails and the buyer is refunded. MCP has no status codes, so `isError` is how you say a call failed. Without it, the buyer would be charged for an error message. Because MCP can't say *whose* fault an `isError` was, it counts toward the circuit breaker like a thrown handler: three in a row pause that buyer's wallet from you. Validate arguments against your published `schema` so bad input is rare. Return **`structuredContent`** for anything a buyer might want to prove they received. The receipt's `responseHash` covers it (or the `content` blocks, if there's no `structuredContent`), and buyers check that hash. ## Restarting reconciles the listing `sell()` finds your existing capability by provider, tool and address. If your code has changed since the last start, it **updates the listing to match** and tells you what changed: ```ts const { paid, describe, capability } = await seller.sellWithDetails({ /* … */ price: "$0.20" }); capability.changed; // ["priceMinor"] capability.priceMinor; // "20", read back from the marketplace, not echoed from your input capability.capabilityId; // unchanged: buyers who saved it keep working ``` Log `changed` and the stored `priceMinor` at startup, so you see what the marketplace will charge. If reconciling fails, `sell()` throws. Don't catch that and start anyway, or you'll serve a tool at a price your own code disagrees with. A reconcile updates the price, name, description, category, tags, schema and latency. It **never** moves the address or renames the tool, because a saved capability id has to keep pointing at the same thing. To move a tool, [deprecate it and publish again](https://docs.zanora.dev/sellers/listings.md). ## `sellAll()`: price a whole server ```ts const catalogue = await seller.sellAll({ category: "finance", url: "https://premium-api.example/mcp", tools: [ { tool: "financial_analysis", description: "Advanced AI-powered financial analysis" }, { tool: "backtest", description: "Backtest a strategy against historical data" }, { tool: "ping", description: "Health check" }, ], prices: { backtest: "$0.50", default: "$0.10" }, skip: ["ping"], // stays free }); server.registerTool( "backtest", catalogue.describe("backtest", { description: "Backtest a strategy", inputSchema: { strategy: z.string() } }), catalogue.paid("backtest")(async (args) => ({ content: [{ type: "text", text: await backtest(args) }] })), ); ``` - `prices.default` covers every tool not named. A tool with **no** price is an error, never a free tool. - Nothing is published until every price is known, so a failed start never leaves half a catalogue up. - `catalogue.paid("ping")` throws for a skipped tool, rather than silently giving a paid tool away. - Instead of listing `tools`, pass `from:` anything with `listTools()` (an MCP client or server) to reuse its descriptions and schemas. ## Show the price in `tools/list` MCP has no built-in idea of a paid tool. `describe` adds the price to a tool definition: a sentence in the description for the model, and `_meta["zanora.dev/x402-price"]` for clients: ```ts server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [describe({ name: "financial_analysis", description: "…", inputSchema })], })); ``` This is only information for the buyer. The price actually charged is always the one in the challenge Zanora signs when the call is made. ## Low-level servers With `Server` and `setRequestHandler`, use `zanoraTool` and pass the call's params: ```ts import { zanoraTool } from "@zanora/middleware-mcp"; import { HttpBackend } from "@zanora/core"; const paid = zanoraTool({ backend: new HttpBackend({ apiKey: process.env.ZANORA_API_KEY }), // gateway defaults to https://api.zanora.dev capabilityId: "cap_…", }); server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === "financial_analysis") { return paid(request.params, async (args, context) => { // context: { transactionId, walletId, agentId } return { content: [{ type: "text", text: await analyse(String(args.ticker)) }] }; }); } // …free tools, unwrapped }); ``` ## Serving it Buyers' agents call your URL over **Streamable HTTP**, so it must be reachable from the public internet. Run a new server and transport for each request, with no session state. Payment state lives in Zanora, so this works behind a load balancer: ```ts app.post("/mcp", async (req, res) => { const server = buildServer(); // registers your tools with paid(...) const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on("close", () => { void transport.close(); void server.close(); }); await server.connect(transport); await transport.handleRequest(req, res, req.body); }); ``` Your server only sends requests *out* to Zanora during payment. Zanora never calls in. That's why a stdio server can be sold [without hosting](https://docs.zanora.dev/sellers/packages.md). ## Compatibility with generic x402 clients The challenge is also placed in `structuredContent`, and `_meta["x402/payment"]` is accepted as an alternative name for the payment key, so a generic x402 MCP client can see that a tool needs payment and what it costs. Paying still needs a Zanora wallet: the proof is an ed25519 signature that Zanora authorizes, not an on-chain payment. --- # 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. ### Step 1: Publish the capability ```bash title="API" 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": { … } } ``` ```text title="Console" Seller → Publish → Protocol: REST → name, description, category, price, endpoint URL → Publish ``` `priceMinor` is a **string of cents**. Leave out `providerId`, because it comes from your key. Keep the returned `cap_…` id: the middleware needs it. ### Step 2: Add the middleware ```bash npm install @zanora/middleware-express express ``` ```ts title="server.ts" import 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); ``` ### Step 3: Check the handshake ```bash 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](https://docs.zanora.dev/sellers/verification.md), 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](https://docs.zanora.dev/api/x402.md). ## 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: ```bash 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](https://docs.zanora.dev/sellers/listings.md). ## 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 | --- # Sell without hosting > A stdio MCP server can be sold as a pinned npm package that buyers run locally. There's no port and no uptime to maintain, and payment works exactly as for a hosted server. > **Warning — Preview: check credentials before you ship:** > > Your package's payment calls go to Zanora **from the buyer's machine**. On a gateway that requires authentication, including the hosted one, those calls need a credential with `payments:write`. There isn't yet a supported way to give a buyer-run package that credential without publishing your key. **Never put a provider key in a public package.** Until this is supported, host your server (see [Sell an MCP tool](https://docs.zanora.dev/sellers/mcp.md)) or email [support@zanora.dev](mailto:support@zanora.dev) before selling this way. Most MCP servers speak **stdio**. They're started by the client and aren't reachable on a URL. You can still sell one: during payment your server only sends requests *out* to Zanora, and Zanora never has to reach you. So the buyer can run your package, and payment works the same way. ## Publish the package ### Step 1: Publish to npm as usual The package must be **public**, because buyers' machines install it. Private packages are refused. ### Step 2: Sell it with `runsLocally` ```ts import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; const paid = await seller.sell({ tool: "financial_analysis", description: "Advanced AI-powered financial analysis", category: "finance", price: "$0.10", runsLocally: true, // name + exact version from your package.json // runsLocally: "@acme/analysis-mcp@1.4.2" // or name it explicitly }); server.tool("financial_analysis", "…", schema, paid(handler)); await server.connect(new StdioServerTransport()); ``` The capability's address becomes `{ kind: "package", command: "npx", args: ["-y", "@acme/analysis-mcp@1.4.2"], version: "1.4.2" }`. ## What changes | | Hosted (`url`) | Package (`runsLocally`) | |---|---|---| | You run | a public HTTPS server | nothing | | Buyer runs | nothing | your package, over stdio | | Payment, receipts, refunds, policies | the same | the same | | Buyer opt-in | none | **must allow your package by name** | | Version | whatever you deploy | pinned **exactly**, and checked before each start | ## What to know - **Buyers opt in per package.** Buying yours needs the buyer to allow it (`ZANORA_ALLOW_LOCAL_PACKAGES`), so you may sell to fewer agents than a hosted tool would. Say in your description what the package does and what it touches. - **Exact versions only.** Ranges are refused, because buyers are running code: what was reviewed must be what runs. Shipping a new version means publishing that version and updating the capability. - **Your package gets a clean environment.** It doesn't inherit the buyer's environment, only the variables the buyer chooses to pass. If you need an upstream API key, document the variable name. > **Note — Why not just publish on npm for free?:** > > You could. Selling through Zanora lets agents find the package in discovery and pay per call with receipts, within their owners' spending rules. --- # Pricing and editing listings > Change a live capability's price, wording or schema without changing its id. Also covers what can't change, how to withdraw a capability, and how discovery ranks you. ## See your listings ```bash curl -s https://api.zanora.dev/v1/capabilities -H "x-api-key: $ZANORA_API_KEY" # → {"capabilities":[{"capability":{…},"version":{…}}, …]} deprecated ones included ``` In the console: **Seller → Capabilities**. ## Edit a live listing `POST /v1/capabilities/:id` takes a **patch**. Send only the fields that change: ```bash curl -s -X POST https://api.zanora.dev/v1/capabilities/cap_d2383f37… -H "x-api-key: $ZANORA_API_KEY" \ -H 'content-type: application/json' -d '{"priceMinor":"80","tags":["invoices","receipts"]}' ``` | You can change | Effect | |---|---| | `priceMinor`, `currency` | takes effect from the next price quote. Buyers always pay the price in the quote Zanora signs at call time, so repricing is safe at any moment | | `name`, `description`, `category`, `tags` | updated in discovery. Changing the text discovery searches re-indexes the capability | | `schema` | publishes a **new version**, because the version hash covers the schema | | `latencyP50Ms` | your advertised median latency, used in ranking | In the console, each row in **Seller → Capabilities** has an **Edit** form that sends only what you changed. With `@zanora/middleware-mcp`, you don't call this route. Change your source and restart, and [`sell()` reconciles](https://docs.zanora.dev/sellers/mcp.md#restarting-reconciles-the-listing) the listing. ### What can't change The **address**, **protocol** and **tool name** can't be edited. A buyer's saved capability id has to keep pointing at the same thing, so moving any of them is a new capability: 1. Publish the new capability. 2. Deprecate the old one. A capability that isn't `active` refuses edits. ## Withdraw a capability ```bash curl -s -X POST https://api.zanora.dev/v1/capabilities/cap_…/deprecate -H "x-api-key: $ZANORA_API_KEY" ``` It disappears from discovery straight away. Existing references still resolve, so a buyer's saved id gets a clear answer rather than a `404`. ## Ranking well in discovery | Signal | What you control | |---|---| | **Verification** | [ask for it](https://docs.zanora.dev/sellers/verification.md). Unverified sellers don't appear at all | | **Relevance** | write `description` and `tags` in the words a buyer would search for: "extract text from invoices", not "DocuParse v3" | | **Price** | cheaper ranks higher within a buyer's price filter | | **Latency** | an honest `latencyP50Ms`. Buyers can filter on `maxLatencyMs` | | **Reputation** | ratings from buyers who hold a receipt. Serve well, and return `isError` or an error status rather than a bad answer, since a failed call is refunded. Use a 4xx for a bad request, so it isn't counted as your failure | | **A schema** | publish one. Agents read it before paying, and fewer bad requests means fewer failed calls | --- # Manage listings from an MCP client > Run @zanora/mcp in seller mode to publish, edit, request verification, and follow sales and earnings from Claude or any MCP client. It can see money but can't move it. The same `@zanora/mcp` package that buyers use has a **seller profile**. It takes a provider key and no wallet key, and gives the model eleven tools for running your listings. > **Note — It publishes listings; it doesn't serve them:** > > Publishing says what's for sale and where. The endpoint or MCP server at that address still has to run the Zanora middleware ([MCP](https://docs.zanora.dev/sellers/mcp.md) or [HTTP](https://docs.zanora.dev/sellers/rest.md)) to charge for calls. ## Add it ```json title="MCP config" { "mcpServers": { "zanora-seller": { "command": "npx", "args": ["-y", "@zanora/mcp"], "env": { "ZANORA_ROLE": "seller", "ZANORA_API_KEY": "zk.akey_…" } } } } ``` The key is checked against the gateway when the server starts, so a buyer key here fails immediately. A wallet key in a seller config is also refused: sellers don't sign payments, so a spending key there is a mistake. ## The tools | Tool | Does | |---|---| | `zanora_provider_status` | verified? suspended? reputation? and why nothing is selling | | `zanora_request_verification` | asks an operator to make you discoverable, with your note | | `zanora_publish_capability` | publishes one capability: REST or MCP, a URL or a pinned package | | `zanora_publish_capabilities` | publishes a whole catalogue in one call | | `zanora_update_capability` | edits price, wording, tags, schema or latency, keeping the id. Refuses to move the address | | `zanora_list_capabilities` | your own listings, including deprecated ones | | `zanora_deprecate_capability` | withdraws a capability from sale | | `zanora_sales` | signed receipts for what you sold. Pages with `after` | | `zanora_earnings` | your settlement wallet's balance | | `zanora_payout_destinations` | **reads** where payouts go | | `zanora_rail_transactions` | payouts and their real state | There's **no payout tool and no tool to change your payout destination.** Changing where payouts go is the first thing someone does with a stolen credential. That's a person's action in the console, with a 24-hour freeze and an alert. ## Being told about sales The seller profile serves three resources that clients can **subscribe** to: | Resource | Contents | |---|---| | `zanora://sales` | signed receipts for completed calls, newest first | | `zanora://earnings` | the settlement wallet's balance | | `zanora://catalogue` | everything you've published | While a client is subscribed, the server checks for changes (`ZANORA_SALES_POLL_MS`, default 20 seconds, minimum 5) and sends `notifications/resources/updated` when something changes. A publish, edit or deprecate made through this server updates `zanora://catalogue` straight away. ## Try it ```text Check my Zanora seller status. If I'm not verified, request verification with a note saying we run an invoice OCR API used by ~200 companies. Then publish an MCP capability "extract_invoice" at https://acme.example/mcp for $1.00 in category OCR. ``` --- # Earnings and payouts > How each sale settles to your wallet, where to see sales, and how to register a payout destination and move money to your bank or a USDC address. ## How a sale settles 1. A buyer's call succeeds, and they get a signed receipt straight away. 2. Moments later, settlement credits your **settlement wallet** with the price **less the platform fee** (3% by default, rounded down to the cent). A $1.00 sale adds `"97"`. 3. A failed call earns nothing: the buyer was refunded. ```bash curl -s "https://api.zanora.dev/v1/receipts?limit=50" -H "x-api-key: $ZANORA_API_KEY" # your sales curl -s https://api.zanora.dev/v1/wallets/$SETTLEMENT_WALLET/balance -H "x-api-key: $ZANORA_API_KEY" ``` In the console: **Seller → Sales** and **Seller → Earnings**. Your settlement wallet id came back at signup (`tenant.walletId`). `GET /v1/wallets` with your provider key also returns it. ## Register a payout destination You register a destination **once**, per rail and asset: | Rail | `destination` | `asset` | `chain` | |---|---|---|---| | `bank_transfer` (ACH, wire, SEPA) | the external account id your bank rail gave you, `ext_…`. Account numbers never go through Zanora | `USD` | — | | `usdc_base` | a USDC address on Base, `0x…` | `USDC` | `base` | ```bash curl -s -X PUT https://api.zanora.dev/v1/providers/$PROVIDER_ID/payout-destination \ -H "x-api-key: $ZANORA_API_KEY" -H 'content-type: application/json' \ -d '{"destination":"ext_…","asset":"USD"}' ``` > **Warning — Changes are frozen for 24 hours:** > > Your **first** destination is usable immediately. **Changing** it freezes payouts to that rail and asset for 24 hours and sends an alert, because redirecting payouts is the first thing a stolen credential is used for. The freeze applies to the rail and asset, not the address, so naming the new address directly in a payout doesn't get around it. Registering the same address with different letter case isn't a change. `GET /v1/providers/$PROVIDER_ID/payout-destinations` lists what's registered and when each becomes usable. ## Request a payout ```bash curl -s -X POST https://api.zanora.dev/v1/payouts -H "x-api-key: $ZANORA_API_KEY" \ -H 'content-type: application/json' \ -d '{"walletId":"'"$SETTLEMENT_WALLET"'","amountMinor":"9700","rail":"bank_transfer"}' ``` You don't send a destination: the payout goes to the one registered for that rail. In the console: **Seller → Earnings → Pay out**. A payout isn't finished when the call returns: | Status | Means | |---|---| | `submitted` | your wallet has been debited and the payment rail has the instruction. Money is in transit | | `confirmed` | the rail confirmed it arrived: minutes for USDC, typically 1–2 business days for bank transfers | | `returned` | the rail sent it back, for example because an account was closed. Your wallet is re-credited with a new entry | Follow it with `GET /v1/rail/transactions`. ## Why a payout might be refused | Code | Means | Do | |---|---|---| | `DESTINATION_COOLING_DOWN` | the destination changed less than 24 hours ago | wait. It lifts on its own | | `SANCTIONED_ADDRESS` | sanctions screening blocked the USDC destination | email [support@zanora.dev](mailto:support@zanora.dev) | | `TREASURY_UNAVAILABLE` | a screening or pricing service is down, so the payout is paused rather than guessed | retry later | | `COVERAGE_SHORTFALL` | payouts are paused while the platform reconciles | retry later | | `INSUFFICIENT_FUNDS` | the amount is more than the available balance | request less | | `NOT_SUPPORTED` | this deployment has no payout rail for that currency | check `GET /v1/rail/providers` | --- # Seller troubleshooting > The problems sellers run into most, what causes each one, and the fix. #### Buyers can't find my capability ({"results":[]}) You're almost certainly **not verified yet**. Unverified sellers are hidden from discovery completely. Check with `GET /v1/providers/:id`, or `zanora_provider_status`, and [request verification](https://docs.zanora.dev/sellers/verification.md). If you're verified, check that the capability is `active` and that your description matches what buyers search for. #### Buyers get INVOKE_FAILED — fetch failed Your URL isn't reachable from where their agent runs. Zanora never calls your server; the **buyer's agent** does. A `localhost` or private-network URL can't be bought by anyone else. Deploy it publicly, or use a tunnel (`ngrok http 4024`) and publish that URL. Nothing is charged when this happens. #### My endpoint returns 402 forever, or complete fails with PROVIDER_NOT_FOUND The middleware's gateway (`HttpBackend`'s `gatewayUrl`, or `ZANORA_GATEWAY_URL`) isn't the gateway the capability was published on. Settlement follows the `capabilityId` the middleware was given. Both default to `https://api.zanora.dev`, so check for a leftover override from local testing. #### My endpoint serves calls for free The route isn't behind the middleware. Publishing a capability doesn't make a URL charge; `zanora(...)` or `paid(...)` does. #### zanoraSeller() refuses my key, or can't find my capability Use a **provider** key in `ZANORA_API_KEY`: a buyer's workspace key can't publish. The gateway defaults to `https://api.zanora.dev`. If `ZANORA_GATEWAY_URL` is set, for example left over from local testing, check it points where you meant. #### sell() reports a price I didn't pass That's intended. It reports the **stored** price, and `changed` lists what it just updated to match your code. If the two differ after a restart, the listing had drifted from your source and has been corrected. #### VALIDATION_FAILED when publishing - `priceMinor` must be a string of cents: `"100"`, not `100`. - `protocol` must be `"rest"` or `"mcp"`, and `category` is required. - `mcp` needs `toolName`, and `rest` must not have one. - Give exactly one of `endpoint` or `address`. - A package address must pin an exact version in both `version` and the command line. #### Buyers were refunded and I wasn't paid Your handler threw, responded with an error status (`4xx` or `5xx`), or returned an MCP result with `isError: true`, so the call failed and the buyer was refunded. Three of your own failures in a row (a 5xx, a throw, an `isError` result or a timeout) pause you for that buyer's wallet (`WALLET_PROVIDER_PAUSED`). A 4xx is refunded but doesn't count toward the pause. Check your logs for the `transactionId` in the failed receipt. #### The receipt's responseHash doesn't match for buyers Something changed the response after the middleware hashed it: a proxy that rewrites bodies, or middleware that adds fields afterwards. For MCP, return `structuredContent`, which is what the hash covers. #### A payout says DESTINATION_COOLING_DOWN You changed your payout destination within the last 24 hours. It lifts on its own. See [Earnings and payouts](https://docs.zanora.dev/sellers/payouts.md). ## Still stuck? Check `GET /v1/whoami` first. Then email [support@zanora.dev](mailto:support@zanora.dev) with your `prv_…` id and the `errorId`, `transactionId` or `capabilityId` involved. Never send your API key. --- # API overview > Base URL, authentication, amount format, errors, rate limits and pagination for every Zanora API route. ## Base URL ```text https://api.zanora.dev ``` The API is HTTPS and JSON only. A request that reaches the gateway over plain HTTP is refused with `403 INSECURE_TRANSPORT` before authentication, so a key sent over HTTP isn't accepted. ## Authentication Send your API key in the `x-api-key` header on every request: ```bash curl -s https://api.zanora.dev/v1/whoami -H "x-api-key: zk.akey_….…" ``` These routes need no key: `GET /health`, the four signup routes, and the three account-recovery routes (`login`, `password/forgot`, `password/reset`). | Status | Means | |---|---| | `401 UNAUTHORIZED` | missing, mistyped, expired or revoked key | | `403 FORBIDDEN` | the key lacks the route's **scope** (the message names it), or the record belongs to another tenant | Each route below lists the scope it needs. See [Accounts and API keys](https://docs.zanora.dev/concepts/accounts-and-keys.md) for the full scope table. ## Tenancy comes from the key Routes work out *your* `providerId` or `workspaceId` from your key. **Don't send them** in request bodies. They're ignored for tenant keys, and a query that names another tenant changes nothing. List routes return **your** records only. ## Amounts Every amount is a **string of integer minor units** (cents): `"priceMinor": "100"` is $1.00. A JSON number is rejected with `400 VALIDATION_FAILED`, never rounded. Responses use the same format. See [Money and amounts](https://docs.zanora.dev/concepts/money.md). ## Request bodies - Send `content-type: application/json` **only when there's a body.** An empty body with that header is rejected. - Routes whose body is optional accept no body at all. - Unknown enum values are rejected, not ignored. ## Errors ```json { "error": { "code": "VALIDATION_FAILED", "message": "priceMinor must be a string of integer minor units", "details": {} } } ``` Handle errors by `code`. An unexpected error returns `500` with `"internal error"` and an `errorId`. Quote the `errorId` when you contact [support@zanora.dev](mailto:support@zanora.dev). Every code is listed in [Errors and refusals](https://docs.zanora.dev/concepts/errors.md). ## Rate limits Requests are limited **per key** (or per IP without one), 50 per second by default. Over the limit, you get `429 RATE_LIMITED` with a `retry-after` header. Signup and password-recovery routes have a separate, stricter per-IP limit. If you have many workers, give each its own key rather than sharing one. ## Pagination Lists that grow over time use a **cursor**, not an offset: ```bash curl -s "https://api.zanora.dev/v1/receipts?limit=50" -H "x-api-key: $KEY" # → { "receipts": [ … ], "nextCursor": "rcp_…" } curl -s "https://api.zanora.dev/v1/receipts?limit=50&after=rcp_…" -H "x-api-key: $KEY" ``` `nextCursor` is present only when the page came back full. An unknown cursor is refused rather than starting again from the top. ## Route index | Area | Routes | |---|---| | [Signup and accounts](https://docs.zanora.dev/api/signup-and-accounts.md) | `POST /v1/signup`, `/verify`, `/resend`, `GET /v1/signup/:id`, `/v1/accounts/*` | | [Keys and identity](https://docs.zanora.dev/api/keys.md) | `GET /v1/whoami`, `/v1/auth/self`, `/v1/auth/scopes`, `/v1/auth/keys…` | | [Discovery](https://docs.zanora.dev/api/discovery.md) | `POST /v1/discovery/search` | | [Capabilities](https://docs.zanora.dev/api/capabilities.md) | `POST/GET /v1/capabilities`, `GET/POST /v1/capabilities/:id`, `…/deprecate` | | [Providers](https://docs.zanora.dev/api/providers.md) | `GET /v1/providers`, `/:id`, `…/verification-request`, `…/rate`, `…/payout-destination(s)` | | [Wallets](https://docs.zanora.dev/api/wallets.md) | `/v1/wallets…`: create, list, balance, ledger, top-up, deposit address, freeze | | [Policies and approvals](https://docs.zanora.dev/api/policies-and-approvals.md) | `/v1/policies…`, `/v1/approvals…` | | [Receipts](https://docs.zanora.dev/api/receipts.md) | `GET /v1/receipts`, `/:id`, `/v1/transactions/:id/receipts` | | [Payouts and rails](https://docs.zanora.dev/api/payouts.md) | `POST /v1/payouts`, `GET /v1/rail/providers`, `/v1/rail/transactions` | | [Platform](https://docs.zanora.dev/api/platform.md) | `GET /health`, `/v1/platform/public-key`, `/v1/platform/receipt-keys/:id` | | [x402 protocol](https://docs.zanora.dev/api/x402.md) | `POST /v1/x402/challenge`, `/authorize`, `/complete`, `/fail`, for middleware authors | --- # Signup and accounts > Self-serve signup for buyers and sellers, and the email/password console login. These are the only routes you can call without an API key. ## Signup ### `POST /v1/signup` **No auth.** Claims an address and sends a confirmation email. **Creates nothing yet.** | Field | Type | Required | Notes | |---|---|---|---| | `role` | `"buyer"` \| `"seller"` | yes | | | `email` | string | yes | | | `organizationName` | string | yes | | | `website` | string | no | helps seller verification | | `dailyBudgetMinor` | string | no | buyers only: the workspace's daily cap in cents | | `password` | string | no | enables console sign-in once the email is confirmed | ```json // 202 { "signupId": "sgn_…", "status": "pending", "expiresAt": "…", "emailSent": true } ``` There's one pending signup per email and role. Asking again resends the email for the same signup. The response is identical whether or not the address is already known. ### `POST /v1/signup/verify` **No auth.** Uses the emailed token and creates the tenant and its root key. | Field | Type | Required | |---|---|---| | `token` | string, from the email link | yes | ```json // 201 { "signupId": "sgn_…", "role": "seller", "status": "completed", "tenant": { "kind": "provider", "id": "prv_…", "name": "Acme OCR", "walletId": "wal_…" }, "credential": { "keyId": "akey_…", "token": "zk.akey_….…", "label": "…", "scopes": ["…"] } } ``` `credential.token` is shown **once**. `walletId` (sellers only) is the settlement wallet. The token can be used only once and expires after 24 hours. If two requests use it at the same moment, only one tenant is created. ### `POST /v1/signup/resend` **No auth.** Body `{ role, email }`. Always returns `202`. Invalidates the previous link. ### `GET /v1/signup/:id` **No auth.** The status of a signup by its `signupId`: `pending`, `completed` or `expired`. Returns no email address and no token. ## Console accounts A console session is an ordinary API key of kind `session`, limited to your tenant. ### `POST /v1/accounts/login` **No auth.** Body `{ email, password }`. Returns `{ sessionToken, expiresAt?, scopes, account }`. Failed attempts count toward a lockout. ### `POST /v1/accounts/logout` Revokes **the session presenting it**. It refuses an ordinary API key, so an agent's key can't be revoked from a sign-out button by mistake. ### `GET /v1/accounts/me` The account behind the current session. Returns `404` for an ordinary API key. ### `POST /v1/accounts/password/forgot` **No auth.** Body `{ email }`. Always returns `202` with the same body. ### `POST /v1/accounts/password/reset` **No auth.** Body `{ token, password }`. Revokes every console **session** and **no** API keys, so your agents keep running. ### `POST /v1/accounts/password` Changes the password while signed in. Body `{ currentPassword, newPassword }`. --- # Keys and identity > Check who a key belongs to, and mint, list, rotate and revoke narrower keys. ### `GET /v1/whoami` Any valid key. Shows who the key belongs to and what it can do. Make this the first call when debugging a `401` or `403`. ```json { "principal": "provider(prv_…)", "kind": "provider", "providerId": "prv_…", "scopes": ["…"], "keyId": "akey_…" } ``` ### `GET /v1/auth/self` The same, plus `label`, `source` (`managed` or `static`) and `expiresAt`. ### `GET /v1/auth/scopes` The full list of scopes. Use it to build a scope picker without hard-coding the list. ### `POST /v1/auth/keys` Scope `keys:write`. Mints a key for **your own** tenant. | Field | Type | Notes | |---|---|---| | `label` | string | required. Shown in listings | | `scopes` | string[] | must be a subset of the minting key's scopes. `":*"` is allowed if you hold it | | `expiresInSeconds` | number | can't be later than the minting key's expiry | ```json // 201: the token is returned once, and only its hash is stored { "key": { "id": "akey_…", "label": "invoice-bot", "scopes": ["…"], "expiresAt": "…", "status": "active" }, "token": "zk.akey_….…" } ``` ### `GET /v1/auth/keys` Scope `keys:read`. Your keys, without their secrets. Console sessions aren't listed. Add `?includeRevoked=true` to include revoked keys. ### `GET /v1/auth/keys/:id` Scope `keys:read`. One key's details. ### `POST /v1/auth/keys/:id/rotate` Scope `keys:write`. Issues a new secret with the same scopes. Body `{ graceSeconds? }` keeps the old secret working for a rolling deploy. ### `DELETE /v1/auth/keys/:id` Scope `keys:write`. Revokes a key, effective on the next request. `?cascade=true` also revokes every key minted from it. Use that for a leak. --- # Discovery > Search the marketplace for capabilities, ranked by relevance, price, latency, reputation and verification. ### `POST /v1/discovery/search` Scope `discovery:read`. The body is optional, and every field is optional. | Field | Type | Notes | |---|---|---| | `query` | string | natural language: `"extract text from invoices"` | | `category` | string | exact category | | `protocol` | `"rest"` \| `"mcp"` | | | `maxPriceMinor` | string | cents: `"200"` = at most $2.00 | | `maxLatencyMs` | number | advertised median latency ceiling | | `limit` | integer ≥ 1 | | ```bash curl -s -X POST https://api.zanora.dev/v1/discovery/search -H "x-api-key: $KEY" \ -H 'content-type: application/json' \ -d '{"query":"extract text from invoices","maxPriceMinor":"200","limit":3}' ``` ```json { "results": [ { "capability": { "id": "cap_…", "name": "Invoice OCR (demo)", "protocol": "rest", "priceMinor": "100", "currency": "USD", "category": "OCR", "tags": ["invoices"], "schema": { "…": "…" }, "status": "active" }, "version": { "endpoint": "https://demo-seller.zanora.dev/ocr", "address": { "kind": "url", "url": "…" }, "version": "…" }, "provider": { "id": "prv_…", "name": "Zanora Demo", "verified": true, "reputation": 0.92 }, "score": 0.87, "scoreBreakdown": { "…": 0 } } ] } ``` Only **active** capabilities from **verified, unsuspended** sellers are returned, best first. MCP results include `capability.toolName`. Package capabilities come back with a `package` address. > **Tip — Read the schema before paying:** > > `capability.schema` is the request body's JSON Schema. A request that doesn't match it will probably fail on the seller's side, and a failed call is refunded, but reading the schema first avoids the wasted round trip. --- # Capabilities > Publish, list, fetch, edit and deprecate capabilities. Sellers write; anyone with a key can read what discovery would show. ### `POST /v1/capabilities` Scope `capabilities:write` (provider key). Publishes a capability. `providerId` comes from your key. | Field | Type | Required | Notes | |---|---|---|---| | `name` | string | yes | shown in discovery | | `description` | string | yes | searched and ranked on | | `protocol` | `"rest"` \| `"mcp"` | yes | | | `priceMinor` | string | yes | cents per call | | `category` | string | yes | | | `endpoint` | string | one of | the URL buyers call | | `address` | object | one of | `{kind:"url", url}` or `{kind:"package", command, args, version}`. Give exactly one of `endpoint` or `address` | | `toolName` | string | `mcp` only | **required** for `mcp`, **refused** for `rest` | | `schema` | object | no | JSON Schema of the request body or tool arguments | | `tags` | string[] | no | | | `latencyP50Ms` | number | no | advertised median latency | | `currency` | `"USD"` | no | default `USD` | | `version` | string | no | your version label | A **package** address must use an approved launcher (`npx`) and pin an exact version in **both** `version` and `args` (`["-y","@acme/ocr-mcp@1.4.2"]`). ```json // 200 { "capability": { "id": "cap_…", "status": "active", "…": "…" }, "version": { "id": "…", "endpoint": "…", "address": { "…": "…" }, "hash": "…" } } ``` ### `GET /v1/capabilities` Scope `capabilities:read`, **provider keys only**. Your own listings, including **deprecated** ones: `{ capabilities: [{ capability, version }] }`. A workspace key gets `403`. Buyers use [discovery](https://docs.zanora.dev/api/discovery.md). ### `GET /v1/capabilities/:id` Scope `capabilities:read`. One capability, with `provider` and a `discoverable` flag. `version` (the address) is included only if discovery would show this capability (active, from a verified seller), or if you're its seller. Otherwise the capability is described but has no address to call. ### `POST /v1/capabilities/:id` Scope `capabilities:write`, owner only. **Edits a live listing** and keeps its id. The body is a patch: send only what changes. | Field | Notes | |---|---| | `priceMinor`, `currency` | effective from the next price quote | | `name`, `description`, `category`, `tags` | re-indexed for discovery | | `schema` | publishes a new version | | `latencyP50Ms` | | There's no field for the address, protocol or tool name: those can't change. A capability that isn't `active` refuses edits. Returns `{ capability, changed: ["priceMinor", …], version? }`. ### `POST /v1/capabilities/:id/deprecate` Scope `capabilities:write`, owner only. No body. Removes the capability from discovery. Existing ids still resolve. --- # Providers > Seller profiles, verification requests, ratings and payout destinations. ### `GET /v1/providers` Scope `providers:read`. The result depends on your key: a **provider** key sees itself, and a **workspace** key sees **verified** sellers only (the same rule discovery uses). ### `GET /v1/providers/:id` Scope `providers:read`. One provider: `name`, `verified`, `status` (`pending_verification`, `active` or `suspended`), `reputation` (0 to 1), `website`, and your own `verification` request (`requested`, `approved` or `denied`, with `note`, `reason` and timestamps). ### `POST /v1/providers/:id/verification-request` Scope `providers:read`, **owner only**. Asks to be verified. Body is optional: `{ note?, contact? }`. Asking again while `requested` updates the request and keeps its place in the queue. Asking after a denial starts a new request. See [Getting verified](https://docs.zanora.dev/sellers/verification.md). ### `POST /v1/providers/:id/rate` Scope `ratings:write` (workspace key). Rates a seller for a purchase. | Field | Type | Required | |---|---|---| | `transactionId` | string, from the receipt | yes | | `rating` | integer 1–5 | yes | | `raterWalletId` | string, your wallet that paid | yes | | `raterAgentId` | string | no | Your wallet must hold a receipt for that transaction, from this provider. One rating per transaction. Returns `404` if there's no receipt, `403` if it's not your purchase or the wrong provider, and `400` if it's already rated or out of range. ### `PUT /v1/providers/:id/payout-destination` Scope `payouts:write`, owner only. Registers where payouts go. | Field | Type | Required | Notes | |---|---|---|---| | `destination` | string | yes | `ext_…` for bank rails, a `0x…` address for USDC | | `asset` | `"USD"` \| `"USDC"` | no | | | `chain` | `"base"` | no | for USDC | The first registration is usable immediately. A **change** freezes payouts for that rail and asset for 24 hours and sends an alert. ### `GET /v1/providers/:id/payout-destinations` Scope `payouts:write`, owner only. The registered destinations, one per chain and asset, with when each becomes usable. --- # Wallets > Create and list wallets, read balances and ledgers, fund them, and freeze or unfreeze them. ### `POST /v1/wallets` Scope `wallets:write`. Creates a wallet in your workspace (`workspaceId` comes from your key). | Field | Type | Required | Notes | |---|---|---|---| | `ownerType` | `"agent"` \| `"workspace"` | yes | `agent` for anything that buys | | `ownerId` | string | yes | your label for the agent, e.g. `invoice-bot` | | `publicKeyPem` | string | agents: yes | the ed25519 **public** key that payment proofs are checked against | ### `GET /v1/wallets` Scope `wallets:read`. Your wallets: `{ wallets }`. A provider key returns its settlement wallet. ### `GET /v1/wallets/:id` Scope `wallets:read`. Wallet details: owner, status, and whether it's frozen. ### `GET /v1/wallets/:id/balance` Scope `wallets:read`. `{ balanceMinor, availableMinor, currency }`. Available is the balance minus holds. It's calculated from the ledger every time. ### `GET /v1/wallets/:id/ledger` Scope `wallets:read`. Every ledger entry for the wallet (amounts as strings). Refunds and corrections appear as new entries. Nothing is removed. ### `POST /v1/wallets/:id/topup` Scope `wallets:write`. Starts a card or bank payment into the wallet. Body `{ amountMinor, asset?, treasuryProviderId? }`. Returns `{ intent: { externalRef, status, amountMinor, hostedUrl?, clientSecret?, declineReason? } }`. The wallet is credited when the payment processor **confirms**. Returns `501 NOT_SUPPORTED` if no connected provider takes top-ups. ### `POST /v1/wallets/:id/deposit-address` Scope `wallets:write`. Gets or creates the wallet's USDC deposit address. Body is optional: `{ asset?: "USDC", chain?: "base", treasuryProviderId? }`. It's idempotent: asking twice returns the same address. Returns `{ address: { address, chain, asset, walletId, treasuryProviderId, createdAt } }`. ### `GET /v1/wallets/:id/deposit-addresses` Scope `wallets:read`. The wallet's registered deposit addresses. ### `POST /v1/wallets/:id/faucet` Scope `wallets:write`. **Testnet deployments only.** Sends test USDC to the wallet's deposit address. Body is optional: `{ address? }`. ### `POST /v1/wallets/:id/fund` Scope `wallets:write`. **Local and sandbox gateways only.** Credits the wallet instantly against a simulated deposit. Body `{ amountMinor, rail? }`. Returns `501 NOT_SUPPORTED` anywhere a real payment provider is connected. ### `POST /v1/wallets/:id/freeze` Scope `wallets:write`. Blocks all outgoing payments. No body. ### `POST /v1/wallets/:id/unfreeze` Scope `wallets:write`. Reverses a freeze. No body. ### `POST /v1/wallets/:id/resume-provider` Scope `wallets:write`. Lifts the pause on a seller that failed three calls in a row for this wallet (5xx, thrown handler, MCP `isError` or timeout; 4xx responses don't count). Body `{ providerId }`. Returns `{ ok, paused }`. ### `GET /v1/wallets/:id/breaker/:providerId` Scope `wallets:read`. Whether a seller is paused for this wallet: `{ walletId, providerId, paused }`. --- # Policies and approvals > Register, list and switch spending rules on or off, and resolve purchases that a rule held for a person. ## Policies ### `POST /v1/policies` Scope `policies:write` (workspace key). Registers a policy document. `workspaceId` comes from your key. | Field | Type | Required | |---|---|---| | `document` | string, in the [policy language](https://docs.zanora.dev/buyers/policies.md) | yes | ```bash curl -s -X POST https://api.zanora.dev/v1/policies -H "x-api-key: $WKEY" \ -H 'content-type: application/json' -d '{"document":"approval:\n price > 5"}' # → {"policies":[{"id":"pol_…","policyType":"approval","enabled":true,…}]} ``` A document with several sections becomes several policies. A line that can't be parsed rejects the whole document with `400`. ### `GET /v1/policies` Scope `policies:read`. Your workspace's policies, including disabled ones: `{ policies }`. ### `POST /v1/policies/:id/disable` Scope `policies:write`. Stops applying a policy from the next purchase. No body. Returns `{ policy }`. ### `POST /v1/policies/:id/enable` Scope `policies:write`. Starts applying it again. No body. ## Approvals ### `GET /v1/approvals` Scope `approvals:read`. Your workspace's approval requests: `{ approvals }`. Filter with `?status=pending` (or `approved`, `denied`). ```json { "approvals": [{ "id": "apr_…", "agentId": "invoice-bot", "walletId": "wal_…", "capabilityId": "cap_…", "amountMinor": "1200", "currency": "USD", "rule": "price > 5", "status": "pending", "createdAt": "…" }] } ``` ### `POST /v1/approvals/:id/approve` Scope `approvals:write`. Approves the request. Body is optional: `{ resolvedBy? }`. The approval covers **one** later purchase by that wallet of that capability, at up to the approved amount. ### `POST /v1/approvals/:id/deny` Scope `approvals:write`. Denies it. Body is optional: `{ resolvedBy? }`. > **Tip — Keep approvals:write away from agents:** > > Give agents a key without `approvals:write`, so a model can never approve its own held spend. See [Approvals](https://docs.zanora.dev/buyers/approvals.md#keeping-approval-with-people). --- # Receipts > List and fetch signed receipts. Buyers see their purchases and sellers see their sales. ### `GET /v1/receipts` Scope `receipts:read`. Newest first. What you get depends on your key: | Key | Returns | |---|---| | workspace | purchases across all your agent wallets | | provider | your sales | | Query | Notes | |---|---| | `limit` | 1–200, default 50 | | `after` | a receipt id: return the page after it | ```json { "receipts": [{ "id": "rcp_…", "transactionId": "txn_…", "providerId": "prv_…", "consumerId": "wal_…", "capabilityId": "cap_…", "amount": { "amountMinor": "100", "currency": "USD" }, "responseHash": "…", "status": "success", "timestamp": "…", "signature": "…", "signingKeyId": "…" }], "nextCursor": "rcp_…" } ``` `nextCursor` is present only when the page is full. Pass it as `after` to get the next page. An unknown cursor is refused. ### `GET /v1/receipts/:id` Scope `receipts:read`. One receipt, if it's yours (as buyer or seller). ### `GET /v1/transactions/:id/receipts` Scope `receipts:read`. Every receipt for a transaction: `{ receipts }`. A failed-then-refunded call can have more than one. See [Receipts](https://docs.zanora.dev/concepts/receipts.md) for what each field means and how to verify a signature. --- # Payouts and rails > Request seller payouts, see which funding and payout rails this deployment offers, and follow money moving in or out. ### `POST /v1/payouts` Scope `payouts:write` (provider key). Pays out from your settlement wallet. | Field | Type | Required | Notes | |---|---|---|---| | `walletId` | string | yes | your settlement wallet | | `amountMinor` | string | yes | cents | | `rail` | `"bank_transfer"` \| `"ach"` \| `"usdc_base"` | yes | | | `destination` | string | no | leave it out to use your [registered destination](https://docs.zanora.dev/api/providers.md#put-v1-providers-id-payout-destination). Recommended | | `treasuryProviderId` | string | no | only if two payout providers serve the same rail | It's idempotent per payout. On a real rail it returns `status: "submitted"`: your wallet has been debited and the money is in transit. It becomes `confirmed` when the rail confirms, or `returned` (re-credited with a new entry) if the payment comes back. Refusals: `DESTINATION_COOLING_DOWN`, `SANCTIONED_ADDRESS`, `TREASURY_UNAVAILABLE`, `COVERAGE_SHORTFALL`, `INSUFFICIENT_FUNDS`. See [Earnings and payouts](https://docs.zanora.dev/sellers/payouts.md#why-a-payout-might-be-refused). ### `GET /v1/rail/providers` Scope `rail:read`. **What this deployment can do.** Ask before offering a funding or payout option. ```json { "sandbox": false, "providers": [{ "id": "circle", "name": "Circle", "adapterType": "custodian", "status": "active", "roles": ["funding", "payout"], "currencies": ["USDC"], "rails": ["usdc_base"], "topUps": false, "depositAddresses": true, "faucet": false }] } ``` `roles` says what a provider is allowed to do. `topUps`, `depositAddresses` and `faucet` say what you can ask it for. `sandbox: true` means development shortcuts such as `POST /v1/wallets/:id/fund` work. ### `GET /v1/rail/transactions` Scope `rail:read`. Every deposit and payout on wallets you own, with its state (`pending`, `held`, `submitted`, `confirmed`, `returned` …) and both amounts: the amount on the rail, and the amount credited in USD. --- # Platform keys and health > The public keys that sign price challenges and receipts, and the health check. None of these need an API key. ### `GET /health` **No auth.** `{ status, instance, time }`. `instance` is the current challenge-signing key id. ### `GET /v1/platform/public-key` The ed25519 public key that signs **402 challenges**: `{ publicKeyPem, signingKeyId }`. Clients check every challenge against it before signing a payment. The SDKs cache it. ### `GET /v1/platform/receipt-keys/:keyId` The public key for a **receipt** signing key: `{ keyId, publicKeyPem }`. Old keys stay available after rotation, so an old receipt can always be checked. Returns `404` for an unknown key id. ## Verifying a signature yourself Challenges, payment proofs and receipts are signed the same way: 1. Take the object and remove `signature` and `signingKeyId`. 2. Serialise it as **canonical JSON**: keys sorted at every level, no extra whitespace, amounts as strings. 3. Verify the base64 `signature` with ed25519 against the public key. `verifyPayload(body, signature, publicKeyPem)` in `@zanora/core` does this. --- # x402 protocol > The payment handshake step by step, over HTTP and over MCP. For anyone writing a buyer client or seller middleware in a language without a Zanora package. You only need this page if you're implementing the protocol yourself. The SDKs and middleware do all of it. ## The sequence ```text Buyer Seller middleware Gateway │ call (no payment) ───────► │ POST /v1/x402/challenge ──────► │ │ ◄──── 402 + challenge ──── │ ◄─────────── signed challenge ── │ │ verify challenge, sign proof │ │ call + proof ────────────► │ POST /v1/x402/authorize ──────► │ policy, balance, hold │ │ ◄─────── { ok, transactionId } ─ │ │ │ run handler │ │ │ POST /v1/x402/complete ───────► │ signed receipt │ ◄── response + receipt ─── │ (or /v1/x402/fail → refund) │ ``` ## Seller side: four gateway routes All four need scope `payments:write` and a **provider** key that owns the capability. ### `POST /v1/x402/challenge` Body `{ capabilityId }`. Returns a signed challenge. The price comes from the listing, not from you: ```json { "capabilityId": "cap_…", "providerId": "prv_…", "amountMinor": "100", "currency": "USD", "nonce": "…", "expiresAt": "…", "payTo": "wal_…", "signature": "…", "signingKeyId": "…" } ``` The nonce can be used once, and the challenge expires. Return it to the buyer unchanged. ### `POST /v1/x402/authorize` Body `{ proof }`, where `proof` is the buyer's decoded payment proof. The gateway checks the signature against the wallet's registered public key, rejects nonce replays and expired challenges, applies the buyer's policy and budget, checks the balance, and takes the money into a hold. ```json // 200 { "ok": true, "transactionId": "txn_…" } // 402 { "ok": false, "code": "POLICY_DENIED", "reason": "denied by rule: price > 20", "decision": { "…": "…" }, "approvalId": "apr_…" } ``` On `ok: false`, **don't run the handler.** Return the refusal to the buyer: status `403` for `POLICY_DENIED` or `APPROVAL_REQUIRED`, `402` otherwise, with body `{ error: { code, message }, decision?, approvalId? }`. ### `POST /v1/x402/complete` Body `{ transactionId, responseBodyBase64 }`, the **exact bytes** you'll send the buyer, base64-encoded. Returns the signed receipt, with `responseHash = sha256(bytes)`. Send it to the buyer. ### `POST /v1/x402/fail` Body `{ transactionId, error, kind? }`. Marks the call failed and refunds the buyer. Returns a `failed` receipt. Call this whenever the handler throws or returns an error. | `kind` | When | Counts toward the circuit breaker | |---|---|---| | `provider_error` (default) | the handler threw, answered 5xx, returned an MCP `isError`, or never answered | yes | | `client_error` | you refused the buyer's request, e.g. an HTTP 4xx | no. The buyer is still refunded | The gateway itself records `timeout` for calls nobody completed, and those count. If neither `complete` nor `fail` arrives, the gateway refunds the buyer after 15 minutes. ## Buyer side 1. Call the capability without payment. You get a challenge: HTTP `402` with body `{ error, challenge }`, or an MCP error result with the challenge in `_meta["zanora.dev/x402-challenge"]`. 2. **Verify the challenge**: remove `signature` and `signingKeyId`, serialise as canonical JSON, and check the ed25519 signature against `GET /v1/platform/public-key`. Refuse if it doesn't verify, if it has expired, or if `amountMinor` is above your own limit. 3. **Sign a proof** with the wallet's private key over canonical JSON of: ```json { "walletId": "wal_…", "agentId": "invoice-bot", "capabilityId": "cap_…", "providerId": "prv_…", "amountMinor": "100", "currency": "USD", "nonce": "", "issuedAt": "2026-09-24T10:12:03.114Z" } ``` and add the result as `signature` (base64). 4. Call again with the proof attached (see the table below). 5. Read the receipt. Verify its signature against `GET /v1/platform/receipt-keys/:signingKeyId`, and check that `responseHash` equals `sha256` of the body you received (for MCP, of the `structuredContent`, or of the `content` blocks if there's none). ## The two transports | | HTTP | MCP | |---|---|---| | Price quote | status `402`, body `{ error: { code: "PAYMENT_REQUIRED" }, challenge }` | `isError` result, `_meta["zanora.dev/x402-challenge"]` (also in `structuredContent`) | | Payment | header `x-payment: base64(JSON(proof))` | `params._meta["zanora.dev/x402-payment"]` = base64(JSON(proof)) | | Receipt | header `x-zanora-receipt: base64(JSON(receipt))` | `result._meta["zanora.dev/x402-receipt"]` = base64(JSON(receipt)) | | Failure signal | status ≥ 400, or a thrown handler. 4xx is reported as `client_error` | `isError: true`, or a thrown handler (both `provider_error`) | The encodings are identical, so REST and MCP are one protocol over two transports. `_meta["x402/payment"]` is also accepted as the payment key, for compatibility with generic x402 MCP clients. ## Canonical JSON Sort object keys at every level, write amounts as strings, add no whitespace, and leave out the `signature` and `signingKeyId` fields. It's the same for challenges, proofs and receipts. `canonicalJson`, `signPayload` and `verifyPayload` in `@zanora/core` are the reference implementation. --- # MCP server reference > Every environment variable, tool and resource in @zanora/mcp, for both the buyer and seller profiles. ```bash npx -y @zanora/mcp # stdio. stdout carries the protocol; logs go to stderr ``` ## Environment | Variable | Profile | Required | Default | Meaning | |---|---|---|---|---| | `ZANORA_ROLE` | both | no | `buyer` | `buyer` or `seller`. Never guessed from other variables | | `ZANORA_API_KEY` | both | yes¹ | — | workspace key (buyer) or provider key (seller) | | `ZANORA_GATEWAY_URL` | both | no | `https://api.zanora.dev` | | | `ZANORA_AGENT_KEY_FILE` | buyer | yes² | — | path to the wallet's ed25519 private key PEM | | `ZANORA_AGENT_KEY` | buyer | yes² | — | the PEM inline (escaped `\n` accepted) | | `ZANORA_WALLET_ID` | buyer | no | derived | needed only if the workspace has several agent wallets | | `ZANORA_AGENT_ID` | buyer | no | wallet id | the agent name on payment proofs | | `ZANORA_MAX_PRICE_MINOR` | buyer | no | none | per-call ceiling in cents. `5.00` is rejected; write `500` | | `ZANORA_APPROVAL_ELICITATION` | buyer | no | `on` | `off` to never prompt the user about held spends | | `ZANORA_ALLOW_LOCAL_PACKAGES` | buyer | no | none | comma-separated packages this agent may run, e.g. `@acme/ocr-mcp,@acme/tts@2.1.0` | | `ZANORA_SELLER_HEADERS` | buyer | no | — | JSON keyed by seller origin: `{"https://seller.example":{"authorization":"Bearer …"}}` | | `ZANORA_SALES_POLL_MS` | seller | no | `20000` | how often to check for changes while a resource is subscribed. Minimum 5000 | ¹ Required on any real deployment. Without it, every tool returns `UNAUTHORIZED`. ² One of the two. The seller profile refuses a wallet key. Every value is checked at startup. A bad value fails immediately with the variable's name. ## Buyer tools | Tool | Arguments | Spends | |---|---|:-:| | `zanora_discover` | `query?`, `category?`, `protocol?`, `maxPriceMinor?`, `maxLatencyMs?`, `limit?` | | | `zanora_capability` | `capabilityId` | | | `zanora_invoke` | `capabilityId`, `body` (the request body; for MCP, the tool's arguments), `method?`, `maxPriceMinor?` (can only lower the ceiling) | **✓** | | `zanora_wallet_balance` | — | | | `zanora_wallet_ledger` | — | | | `zanora_purchases` | `limit?`, `after?` | | | `zanora_receipt` | `receiptId` | | | `zanora_rate_provider` | `providerId`, `transactionId`, `rating` (1–5) | | | `zanora_deposit_addresses` | — | | | `zanora_approval_status` | `approvalId` | | Resources: `zanora://wallet`, `zanora://purchases`. ## Seller tools | Tool | Does | |---|---| | `zanora_provider_status` | verification, suspension, reputation, and why nothing is selling | | `zanora_request_verification` | asks to be verified (`note?`, `contact?`) | | `zanora_publish_capability` | publishes one capability (`endpoint` or `runsLocally`) | | `zanora_publish_capabilities` | publishes several at once | | `zanora_update_capability` | edits the price, wording, tags, schema or latency | | `zanora_list_capabilities` | your listings, including deprecated ones | | `zanora_deprecate_capability` | withdraws a capability | | `zanora_sales` | receipts for your sales (`limit?`, `after?`) | | `zanora_earnings` | settlement wallet balance | | `zanora_payout_destinations` | reads your payout destinations | | `zanora_rail_transactions` | your payouts and their state | Resources, all subscribable: `zanora://sales`, `zanora://earnings`, `zanora://catalogue`. ## Protocol details - Every tool declares an `outputSchema` and returns `structuredContent`. Errors carry no structured content. - `zanora_invoke` sends four named progress steps when the client provides a progress token. - Long lists page with `after` and return `nextCursor`. - Where the client supports elicitation, `zanora_invoke` can ask the user to approve a held spend, or to fund an empty wallet. ## Embedding it ```ts import { createZanoraMcpServer, loadConfigFromEnv } from "@zanora/mcp"; const { server } = createZanoraMcpServer(await loadConfigFromEnv()); await server.connect(myTransport); // Seller profile: createZanoraSellerMcpServer(loadSellerConfigFromEnv()) ``` --- # SDK and middleware reference > The public surface of @zanora/sdk, @zanora/middleware-mcp and @zanora/middleware-express, in one place. ## `@zanora/sdk` ### `new ZanoraAgent(options)` | Option | Type | Notes | |---|---|---| | `agentId` | `string` | required | | `walletId` | `string` | required | | `privateKeyPem` | `string` | required: the ed25519 private key | | `apiKey` | `string` | workspace key. Required on any real deployment | | `maxPriceMinor` | `bigint` | client-side ceiling | | `mcpCaller` | `McpToolCaller` | required for `invokeMcp`. See `@zanora/mcp` | | `gatewayUrl` | `string` | defaults to `DEFAULT_GATEWAY_URL` (`https://api.zanora.dev`) | | Method | Returns | |---|---| | `discover({ query?, category?, protocol?, maxPriceMinor?, maxLatencyMs?, limit? })` | `{ results: [{ capability, version, provider, score }] }` | | `invoke(url, { body?, method?, headers?, signal? })` | `InvokeResult` | | `invokeMcp(address, { tool, arguments?, signal? })` | `InvokeResult`, plus `mcpResult` | | `verifyReceipt(receipt)` | `Promise` | | `balance()` | `{ balanceMinor, availableMinor, currency }` | | `rateProvider(providerId, transactionId, rating)` | — | ```ts interface InvokeResult { status: number; data: T; receipt?: Receipt; receiptVerified: boolean; // Zanora signed it responseHashVerified: boolean; // it covers this response transactionId?: string; mcpResult?: McpToolCallResult; // invokeMcp only: the seller's result, unmodified } ``` | Error class | `code` values | Money moved? | |---|---|---| | `ZanoraPolicyRejection` | `POLICY_DENIED`, `APPROVAL_REQUIRED` (+ `approvalId`), `INSUFFICIENT_FUNDS`, … | no | | `ZanoraPaymentError` | `PRICE_ABOVE_CLIENT_LIMIT`, `CANCELLED`, `INVALID_CHALLENGE`, `MCP_TRANSPORT_UNAVAILABLE` | no | Also exported: `ZanoraClient` (typed API client, same `gatewayUrl` default), `generateEd25519KeyPair()`, `DEFAULT_GATEWAY_URL`. ## `@zanora/middleware-mcp` | Export | Use | |---|---| | `zanoraSeller({ gatewayUrl?, apiKey? })` | gateway: the option, then `ZANORA_GATEWAY_URL`, then `https://api.zanora.dev`. Key: the option, then `ZANORA_API_KEY`, and it must be a provider key | | `seller.sell(input)` | publishes or reconciles one tool, and returns the `paid` wrapper | | `seller.sellWithDetails(input)` | returns `{ paid, describe, capability: { capabilityId, published, changed, priceMinor, currency, endpoint } }` | | `seller.sellAll({ tools?, from?, prices, skip?, category, url? \| runsLocally?, … })` | returns `{ paid(tool), describe(tool, def), tools, capabilities }` | | `zanoraTool({ backend, capabilityId })` | the low-level wrapper: `paid(params, handler)` | `paid` has two forms: `paid(handler)` for `McpServer.tool()`, and `paid(params, handler)` for `setRequestHandler`. The handler's second argument is `{ transactionId, walletId, agentId }`. ## `@zanora/middleware-express` | Export | Use | |---|---| | `new HttpBackend({ gatewayUrl?, apiKey })` | the gateway connection, using your provider key. The gateway defaults to `https://api.zanora.dev`. The positional form `new HttpBackend(gatewayUrl, apiKey)` still works | | `zanora({ backend, capabilityId })` | Express middleware. Sets `req.zanora = { transactionId, walletId, agentId }` | ## `@zanora/core` | Export | Use | |---|---| | `DEFAULT_GATEWAY_URL` | `"https://api.zanora.dev"`, the default every client package uses | | `resolveCapabilityAddress(version)` | the `url` or `package` address to call or run | | `canonicalJson`, `signPayload`, `verifyPayload` | the signing scheme for challenges, proofs and receipts | | `generateEd25519KeyPair()` | `{ publicKeyPem, privateKeyPem }` | | `HttpBackend`, `ZanoraBackend` | the seller-side gateway interface both middlewares use |