API reference#
Base URL https://api.mechawallet.com. The live OpenAPI schema is at /openapi.json, with a browsable version at /docs.
Authentication#
Authorization: Bearer mw_live_...
Sellers only — buyers never authenticate. A browser session cookie works anywhere a key does, which is how /create and /seller operate.
| Status | Meaning |
|---|---|
401 |
Missing, malformed, or revoked key |
403 |
Key lacks the required scope |
Checkouts#
POST /v1/checkouts#
Create a checkout. Returns 201.
| Field | Type | |
|---|---|---|
title |
string | required |
price_usd |
string | required — decimal string, e.g. "0.10" |
pay_to |
string | your payout wallet. Optional — omit it and your account's standing payout wallet for each chain is copied onto the checkout at create |
description |
string | |
fulfillment |
inline | relay | mcp | webhook |
inferred from what you send |
payload |
any | the goods, for inline |
origin_url |
string | your endpoint, for relay; your MCP server for mcp |
origin_method |
GET | POST | PUT | PATCH | DELETE |
default GET; forced to POST for mcp. Body-taking methods receive the buyer's reserved body param (a signed JSON string, ≤8000 chars) as the request body |
mcp_tool |
string | mcp only — sell ONE named tool. Sending it implies mcp. Omit it and one price buys any tool on the server |
max_deliveries (calls) |
int | sell a package. relay and mcp only, minimum $1.00 |
package_ttl_days |
int | how long a package entitlement stays spendable. Default 30 |
reusable |
bool | true = payment link; default single-shot |
test_mode |
bool | not armed — the whole checkout rehearses (simulated settlement, no funds) until you go-live. Test keys and OAuth creates default to it |
test_delivery_daily_cap |
int | rehearsal budget for origin-backed (relay/mcp) test deliveries, per day. Default 25, minimum 1 |
expires_in_minutes |
int | default 7 days |
input_schema, output_schema |
object | offer card |
example_input, example_output |
object | offer card |
freshness, coverage, source, constraints |
string | offer card |
content |
object | makes it a gated article: the id comes back cnt_ and it answers at /x/{id} instead of /c/{id}. See Gated content below, and the guide |
curl -X POST https://api.mechawallet.com/v1/checkouts \
-H "Authorization: Bearer $MW_KEY" -H "content-type: application/json" \
-d '{"title":"Financials","price_usd":"0.10","pay_to":"0x...","payload":{"revenue":1}}'
Idempotency-Key (optional header). Send one if you might retry. A repeat
with the same key returns the original checkout and Idempotency-Replayed: true
instead of creating a second one — so a timeout that you retry does not leave you
selling the same thing twice.
Keys are scoped to your account, so pick whatever you like without worrying about collisions with other sellers. A create that fails releases the key, so you can fix the request and retry with the same one.
curl -X POST https://api.mechawallet.com/v1/checkouts \
-H "Authorization: Bearer $MW_KEY" -H "Idempotency-Key: order-8891" \
-H "content-type: application/json" -d '{ … }'
GET /v1/checkouts#
Your checkouts, newest first. { "checkouts": [...] }.
PATCH /v1/checkouts/{id}#
Update title, description, and any offer-card field.
Price and payout are deliberately immutable
price_usd and pay_to cannot be patched — they change what a buyer may already have been quoted. Create a new checkout instead. An unknown field is a 422, never a silent no-op.
Lifecycle#
POST /v1/checkouts/{id}/go-live |
Arm an unarmed checkout for real money — in place: same id, same URL. Idempotent |
DELETE /v1/checkouts/{id} |
Retire it. Soft — the row survives so receipts still resolve. Returns {voided: [...]} |
POST /v1/checkouts/{id}/restore |
Un-retire. Status is recomputed, not remembered: a one-shot that was already paid comes back paid, not open |
POST /v1/checkouts/{id}/extend |
Move or clear the deadline — in place, same URL. An expired link a seller already shared starts selling again, byte-identical. Void and paid refuse: their state is not a deadline problem |
POST /v1/checkouts/{id}/payouts |
Add a payout rail — additive only, same id, same URL. Price and existing addresses untouched; a chain that already has an address is a 409. The test face gains the rail with no extra step |
Arming does not mint anything. The rehearsal is the same checkout's
/c/{id}/test face, which can never be armed — so the address you rehearse at
and the address that takes money differ by a path segment that says which is
which, not by two opaque ids. The old fear — a URL in a colleague's terminal
silently starting to take money — is answered structurally: scripts belong on
the test face, and the bare URL's 402 discloses its own state before and
after.
GET /v1/checkouts returns one entry per product, each carrying its rehearsal
address under test: {id, url} (same id, /test URL) and as test_url on
the checkout itself. Pass ?include_voided=true to see retired ones.
The checkout URL#
GET /c/{id}#
Content-negotiated. Accept: text/html gives the pay page; anything else gives 402 plus accepts[] and the offer card.
Send X-PAYMENT on this same URL to settle and receive the goods in one round trip.
POST /c/{id}/prepare#
Build the exact EIP-712 typed data for a buyer to sign.
{ "payer": "0xYourAddress", "network": "eip155:8453" }
Returns typed_data, a payload_stub, and the resolved network. The server signs nothing and holds no key — this exists so a browser doesn't reimplement consensus-critical crypto in JavaScript.
POST /c/{id}/pay#
Same as sending X-PAYMENT to GET /c/{id}, for clients that prefer an explicit POST.
GET /c/{id}/handoff/{nonce} — buyer attribution#
The pairing poll for a payment made somewhere else. Append ?h=<nonce>
([A-Za-z0-9_-]{16,64}, yours to mint) to any pay URL — plain, escrow,
/test — and the settlement stores it. This endpoint answers 404 until a
settled — or escrow-held — payment carries that nonce, then:
{"payment_id": "pay_…", "receipt_url": "…", "escrow_state": null, "test_mode": false}
A held deposit reports escrow_state: "held" — attach the buyer, never book
revenue before release. A wrong nonce is indistinguishable from a payment
that never happened. Also at GET /e/{id}/handoff/{nonce}, and the same
nonce arrives server-side as handoff_id on checkout.paid and every
escrow event. The hosted pay page cooperates: wallet payments forward the
page's ?h=, and qr.svg?h=… folds it into the QR (that response is
no-store; the plain QR caches for a day). The full platform pattern:
build a marketplace.
GET /c/{id}/test — the rehearsal face#
Every checkout has one, forever. Same product, same origin call, simulated
settlement: synthetic 0xtest_… hash, no funds, test_mode: true on the
payment and receipt. /pay and /prepare exist here too (/c/{id}/test/pay,
/c/{id}/test/prepare), so a whole client can run against the test face
unchanged.
- The path decides. Which face settles simulated is the path plus the checkout's armed state — never the payment payload. A test payload on an armed live face is refused.
- The test face can never be armed, lists testnet rails only, and its
accepts[].resourcenames/c/{id}/test. - Rehearsals never flip
status— a single-shot is still buyable after any number of them. - The faces cross-link: a live face's
402carriesendpoint.rehearsal.test_url; a test face states in words that it settles without real money, pluslive_url(or that the seller has not gone live). - Rehearsal budget: origin-backed (
relay/mcp) test deliveries are capped per checkout per day — default 25,test_delivery_daily_capon create to change it. Past the cap is a429with alive_urlhint. Inline and webhook rehearsals are unmetered.
URLs of test twins from the retired twin-row model 307-redirect to
/c/{live_id}/test, preserving method, body and headers — a payment already
in flight survives the hop. Package keys bought on an old twin row keep
working against their original row.
Seller money surfaces stay clean of all this: /v1/receipts and
/v1/receipts/export default to real payments and take ?test_mode=true for
the rehearsal ledger instead — a switch, never a mix.
Directory#
GET /v1/directory#
Paged: limit (up to 200, default 50) and offset walk the ranked list;
the answer carries total, offset, limit and next (the URL of the
next page, or null). /sitemap.xml lists every endpoint page as well, and
/robots.txt welcomes crawlers.
What an agent can buy right now, across sellers: our live checkouts (never
test-mode, never closed) and reviewed third-party x402 endpoints. Public,
Access-Control-Allow-Origin: *, cacheable for FEED_CACHE_SECONDS.
Query: q words matched against name, description, tags, seller and host;
chain (base | solana); max_price_usd (rows without a known price are
dropped when set); category (api, data, search, ai, crypto,
files, article, tools, other); limit (default 50, at most 200).
A bad chain, category or price answers 400 with the reason.
Each row: id, kind (checkout | third_party), name, description,
url, category, chains, tags, price_usd, total_usd, price_source
(live | verified | hint), verified, status (live | ok |
not_402 | unreachable | unchecked), checked_at, source, seller and
pay ({tool: "pay_x402", args: {url}}). Checkout rows also carry the full
offer card from /v1/sellers/{slug}/offers. Verified first, then price.
GET /explore is the same list with two faces: a page for a person, this JSON
for anything else.
Reviews#
Every directory row carries a reviews block: count, average and the latest review with a body. A review needs a receipt: the account must have paid that URL through one of its wallets (a /test rehearsal does not count). One review per account per endpoint; posting again replaces it. Any of the account's credentials may post, a wallet key included. The MCP twin is review_endpoint.
| Method and path | What it does |
|---|---|
GET /v1/directory/{entry_id}/reviews |
The reviews on one row, newest first; public |
GET /v1/directory/totals |
How many endpoints are listed, verified and ours, and how many hosts; public |
POST /v1/inspect |
Look before paying: {url, method?} answers whether it is an x402 endpoint, the price, our listing, and the origin's llms.txt and OpenAPI; any credential, nothing paid Send body (an object) for a POST endpoint that validates its request before naming a price; a listed endpoint's sample body is used when omitted. |
GET /explore/{entry_id} |
One endpoint's page: what it is, its verified price, how an agent buys it, its reviews. A browser gets HTML; anything else the row as JSON with reviews |
GET /explore/{entry_id}/badge.svg |
A small badge an endpoint's owner can show: "x402 · $price · listed on mechawallet" |
GET /sitemap.xml |
The directory and every endpoint page, for crawlers |
POST /v1/directory/{entry_id}/reviews {rating, body?} |
Leaves or replaces this account's review; 201 with the review and the row's summary, 403 without a receipt, 404 for a row not in the directory |
Profiles and payers#
GET /v1/profiles/{slug} · GET /u/{slug}#
Every account has a public page. The JSON carries profile (handle, name,
verified, since, standing: good or reports with the count of open
reports), wallets (the active spending wallet per chain with its address,
opened date and receipt count; archive, the burned wallets with opened,
closed, spent and receipts, or null when the holder hid them; counts)
and offers, the live catalogue. /u/{slug} is the same with two faces.
Never a balance, a limit or a purchase.
GET /v1/payers/{address}#
The attestation a seller calls with a payer address from a receipt:
{mechawallet: true, chain, address, status: active|burned|revoked, since,
account: {slug, name, verified, standing}, profile_url}. 404 for any
address that is not one of our wallets. Public, Access-Control-Allow-Origin: *.
PUT /v1/spending-wallets/profile#
Session only: {archive_public: bool}, whether burned wallets show on the
page. Public by default.
Escrow#
The full story — model, windows, disputes, embedding — is in the escrow guide; this is the endpoint surface.
POST /v1/escrow-checkouts#
Create an escrow checkout: same body as POST /v1/checkouts plus the escrow
terms. Base only for now — Solana's escrow program is deployed but
withheld, because its deposit needs buyer-funded account rent that the
program refunds to the buyer, not to us. A seller with no payout wallet on
an escrow-capable rail is refused at create, in words, rather than given a
checkout no one can pay. Ids start esc_, the page lives at /e/{id}, and
the fee is 1% + $0.02, which REPLACES the standard 0.5% + $0.01 rather than
adding to it — an escrow lands a deposit and a release, plus any relayed
buyer actions we pay for.
{
"price_usd": "250.00",
"title": "Repo transfer: acme/parser",
"escrow_preset": "standard", // instant | standard | shipping
"escrow_settle_window": 259200, // optional per-window overrides, seconds
"escrow_hold_window": 259200,
"escrow_backstop_window": 2592000,
"arbiter_evm": "0x…", "arbiter_svm": "…" // optional; see arbiter rules below
}
Windows clamp to ≥1h; settle/hold ≤30d; backstop ≤180d and > hold. All terms freeze at create — they end up inside the buyer's signature.
Arbiter resolution: explicit on the create → your account's
(PUT /v1/account/escrow) → the mechawallet default. The last step is for
direct sellers only: a platform account creating for a sub-seller must
name its own arbiter or the create is refused — B2B2X disputes are the
platform's to judge, never ours. A chain you name no arbiter for offers no
escrow rail.
On a PLAIN checkout, escrow_offer: "optional" auto-mints a linked escrow
twin and advertises it in the 402 (extra.escrowAvailable); "required"
returns the escrow face as the primary URL.
GET /e/{id} · POST /e/{id}/prepare · pay#
The escrow face speaks the same x402 protocol as /c/{id}. Differences the
buyer sees in the 402: payTo is the escrow contract (Base) or program
(Solana), and accepts[].extra.escrow carries the full terms — windows,
arbiter, both exit routes, and a sentence saying what happens if nobody acts.
Everything in that block is inside the signed nonce, so what is shown is what
is enforceable. On Solana, /prepare returns a partial transaction plus
payer_salt — present both at pay time; the buyer also funds ~0.003 SOL of
account rent, returned when the escrow closes.
A deposit is not a sale. The payment appears in /v1/receipts and fires
checkout.paid only at RELEASE. The escrow.* webhook events
(held, on_hold, escalated, released, refunded) carry the earlier
lifecycle.
Buyer controls — accountless#
POST /e/{id}/actions/{hold|approve|request-refund}?payment_id=…
POST /e/{id}/actions/{action}/submit?payment_id=…
No auth: the endpoints return something only the right wallet can sign — the contract, not us, decides who may act.
Buyers never need gas, on either chain. The prepare call returns
typed_data (Base) or a partially-signed transaction (Solana); sign it and
POST it back to /submit, and mechawallet's relayer pays the fee. On Base
that means {v, r, s, deadline} from the EIP-712 payload — the signature is
the authority, so the contract obeys the buyer, the seller or the arbiter and
never us. transaction is still returned for anyone who prefers to broadcast
themselves; escrows minted before the relaying contract (2026-08-26) offer
only that path, which is why gasless in the response says which one you
have. The seller's one control, cancel (concede and
refund), uses the same shape. GET /e/{id}/status?payment_id=… returns the
live state and a plain sentence saying what happens next.
Disputes — for sellers and platforms#
GET /v1/escrows?status=held|on_hold|escalated
GET /v1/escrows/{payment_id}
POST /v1/escrows/{payment_id}/resolve?to_seller=true|false
POST /v1/escrows/{payment_id}/resolve/submit?to_seller=… (Solana only)
resolve prepares the ruling as an UNSIGNED transaction for the arbiter
wallet named in from_ — mechawallet never holds that key. family says how
to finish: "evm" → sign and broadcast yourself; "svm" → sign your slot and
hand it to /resolve/submit, which broadcasts on mechawallet's gas (your
arbiter wallet needs no SOL). The only two values of to_seller are the two
exits the buyer's deposit committed to.
The dashboard's Live escrows panel carries the same controls for a human;
the MCP tools (list_escrows, get_escrow, resolve_escrow,
resolve_escrow_submit, and the accountless escrow_hold / escrow_approve /
escrow_request_refund / escrow_submit) carry them for an agent.
GET /v1/account/escrow · PUT /v1/account/escrow#
Standing escrow settings: escrow_policy (disabled | seller_choice |
always), escrow_default_preset, escrow_arbiter_evm, escrow_arbiter_svm.
Defaults copied at create, never pointers followed later — an account edit
cannot change terms a buyer already signed. {"arbiter": "payouts"} on the
PUT copies your payout wallets as arbiters (a snapshot, not a link), and a
PUT that changes arbiters answers with their readiness.
GET /v1/account/escrow/readiness#
Can the named arbiter rule today, per chain: gas balance and
rulings_affordable on EVM (a ruling is a signed transaction — no ETH, no
ruling, whoever holds the key), and the plain statement that Solana rulings
ride our relayer's gas. readiness: null means no arbiter is named and
mechawallet adjudicates. The same block arrives as arbiter_readiness in
every escrow.escalated webhook — the warning delivered at the moment it
is actionable. Balance says nothing about custody; the response's
custody_note says so.
Embedding#
GET /v1/account/embed
PUT /v1/account/embed {"embed_origins": ["https://shop.example"]}
GET /embed/check?checkout_id=…&origin=… (public, open CORS)
Nothing may frame /c/, /e/ or /r/ until the seller allowlists origins —
those pages carry wallet-signing buttons. The list REPLACES on each PUT;
wildcards and non-localhost http are refused. embed/check is the
diagnostic embed.js uses to explain a blank frame; the snippet itself is
two lines:
<div data-mw="chk_…"></div>
<script src="https://api.mechawallet.com/static/embed.js" async></script>
Create the checkout with "wallet_button": true or the embed is a read-only
product page. Host pages hear mw:ready/paid/held/on_hold/released/escalated/resize
as DOM events (payload fields per event: escrow guide → Listening);
credit a sale on mw:paid only — mw:held money can still refund.
Other embed shapes, because a frame is not always the right one:
- Button mode —
data-mw-mode="button"(+data-mw-label) renders a Buy link instead of the frame: zero config, no allowlist, no wallet_button. - QR asset —
GET /c/{id}/qr.svg(and/e/{id}/qr.svg, plus/test/variants) is a stable, cacheable SVG QR of that face's URL, for surfaces that are not web pages: invoices, packaging, print, slides. - Other attributes:
data-mw-test="true"(rehearsal face),data-mw-height(frame height before the first resize),data-mw-theme(light/dark/auto— match the host page, not the visitor's OS),data-mw-accent(#RRGGBBfor price and highlights),data-mw-content(a gated piece — see Gated content below),data-mw-price/data-mw-blurb(the lock panel's price and wording),data-mw-open(set by us on the mount once the reader is through),data-mw-grant(a pass your own server issued for a reader you have already authenticated — the article opens with no wall and no payment),data-mw-mediawithdata-mw-kind(a gated photo, clip or recording;data-mw-blurblurs the preview while locked),data-mw-format(flat— no card chrome),data-mw-radius(0–32),data-mw-font(system),data-mw-part(pay/wallet/qr/agent/price— modular surfaces of one checkout; siblings flip to paid together, and the paying parts always carry name + total),data-mw-receipt="pay_…"(a receipt in the frame — the after-sale half; id from thecheckout.paidwebhook or the paid/held events),data-mw-store="slug"(the seller's whole catalogue, rendered natively; withdata-mw-layoutgrid/rows,data-mw-limitN, anddata-mw-buyframe/linkfor how an item's Buy behaves). SPAs that add mounts after load callwindow.mwEmbed.scan().
In-frame paying works with the wallets buyers already have: any EIP-6963 EVM wallet (multi-wallet users get a picker) and any Wallet-Standard Solana wallet; mobile buyers without an extension get open-in-wallet deep links, and desktop buyers without one get the QR handoff.
Connecting an integration (OAuth)#
A plugin or app can connect to a seller's account without anyone pasting an
API key. It is the same OAuth server Claude uses for /mcp — dynamic client
registration at POST /oauth/register, then /oauth/authorize and
/oauth/token — with one scope that the REST API accepts:
| scope | what it reaches |
|---|---|
checkouts.write |
create, change, retire and go live with checkouts; issue reader passes |
The MCP scopes are not accepted here, deliberately. mcp.write maps
internally to full, and on this surface full also mints API keys and
repoints payout wallets — neither of which any consent screen mentions. A
token carrying only mcp.read/mcp.write gets 401 on REST, so no token
already issued gains reach.
checkouts.write is capped at the payments rung. It cannot
PUT /v1/account/wallets and cannot POST /v1/keys: an OAuth token is
revocable and expires, and one able to mint an API key would have escaped its
own leash. Both refuse with 403.
Ask for it by name in the authorize request:
/oauth/authorize?...&scope=checkouts.write
Gated content (Pay to Read / Pay to Scrape)#
The paid part of an article does not stay in the publisher's HTML; it is served
from /x/{id}. Guide with screenshots: Pay to
Read.
Creating one#
POST /v1/checkouts with a content object. The id comes back prefixed
cnt_ and its url is on the /x/ face.
content field |
|
|---|---|
teaser |
the free part. Falls back to description, then the opening of the body |
author, published |
byline, in the markup and on the wall |
canonical_url |
the seller's URL. Ours is noindex and points its canonical here. Its origin is also added to this checkout's frame-ancestors, so the declared page can embed the pay control without a second allowlist entry |
keywords |
seller tags. In the JSON-LD and leading the 402's discovery tags |
section, language, license_url |
passed through to the structured data |
pass_scope |
seller (default) opens everything that account sells; content opens one piece |
pass_hours |
pass lifetime. Default 24, max 8760 |
Escrow and content are refused together (400): content is delivered at
settlement, so there is nothing to hold. Fulfillment must be inline or
relay.
GET /x/{id}#
One URL, three answers, decided by what the caller carries and what it accepts. Never cacheable: the same URL becomes the article the moment a pass is presented.
| the caller | the answer |
|---|---|
a valid pass (header or ?grant=) |
the body. text/html wrapped in .mw-paid, otherwise verbatim |
no pass, Accept: not HTML |
402 with accepts[], plus endpoint.article (teaser, byline, tags, pass terms) |
no pass, Accept: text/html |
the wall |
An ordinary checkout id here is a 404, and a cnt_ id at /c/ is a 404:
the faces are separate namespaces, not aliases. The face is inside the signed
resource, so prepare and pay cannot straddle two of them.
POST /x/{id}/prepare · POST /x/{id}/pay#
Identical to the /c/ pair — the same implementation serves all three faces.
The difference is the response to pay, which carries the pass:
{ "receipt": { "…": "…" },
"grant": "v1.…", "grant_scope": "seller", "grant_expires_in": 86400 }
Presenting a pass#
X-MW-Grant: v1.…
or ?grant=… for a browser, because the publisher's page holds it and passes
it into the frame. It is a signed string, not a database row: verifying it
costs no query and works on any instance. A pass minted for one seller is not
even parseable as valid by another.
GET /v1/content/{id} · editing an article#
The read half of the mini CMS: the article as the seller wrote it, plus the full declaration. Owner only; the body is the paid goods and appears on no public surface.
Edit with the ordinary PATCH — payload (the article text, gated content
only; an ordinary checkout's goods stay frozen) and content (the whole
declaration). PATCHing content replaces the declaration, so
read-modify-write: fetch it here, change the field, send it all back. Edits
are live for the next read, including readers already holding passes. The
dashboard's Manage panel does exactly this.
POST /v1/grants#
A pass for every article this account gates, minted without naming one — for the moment a reader signs in to the publisher's own system, before anyone knows what they will open. Seller credential only.
| field | |
|---|---|
subject |
required. Who it is for, in YOUR terms |
ttl_seconds |
default 24h. Capped at 31 days |
uses |
optional fetch limit |
One per reader per session, cached beside the session. Not one per page view.
POST /v1/content/{id}/grants#
Let a reader you have already authenticated read without paying. Seller credential only: the pass is free access to your own paid content, so the only party who may issue one is the party whose revenue it costs.
| field | |
|---|---|
subject |
required. Who it is for, in YOUR terms. Recorded, never interpreted |
scope |
seller (default) opens everything you gate; content opens this piece |
ttl_seconds |
defaults to the content's pass_hours. Capped at 31 days |
uses |
optional fetch limit |
Returns grant, grant_scope, grant_expires_in. Give it to a browser as
data-mw-grant on the mount, or to an agent as X-MW-Grant.
A chk_/esc_ id here is a 404: only gated content has passes.
GET /x/{id}/snippet#
Public, unauthenticated, and everything in it is public by design. Returns the
block to paste (snippet), plus gate_url, teaser, jsonld and
price_usd.
Platforms (B2B2X)#
GET /v1/account/platform · PUT /v1/account/platform#
One request is the whole setup: {"platform_account": true,
"platform_fee_bps": 200, "platform_fee_to": "0x…"}. The fee is basis
points (0–2000) taken from the sub-seller's price — the buyer's total
never changes — paid to your fee wallet(s). With the mode on, creates
default to inherit_account_payouts: false (your wallets never leak onto a
sub-seller's checkout) and the standing fee fills in where a create names
none. Defaults are copied at create: existing checkouts keep the fee
they froze. GET returns the mode, the standing fee, fee_wallets per
chain, and max_bps so a UI can state the cap before the API enforces it.
A per-create platform_fee_bps always wins over the standing default.
GET /v1/platform/quote#
?price_usd=50.00&platform_fee_bps=250 → buyer_pays /
seller_receives / platform_cut / protocol_fee. Public and
unauthenticated — price a platform before you have an account. Computed by
the same modules that settle, so the preview cannot drift from a payment.
The two rules the numbers teach: your cut comes OUT of the seller's price
(cut + seller == price, exactly); the protocol fee goes ON TOP to the buyer.
Packages#
Set max_deliveries when creating a checkout and you have sold a package: one
payment, one on-chain settlement, N calls. Minimum package price is $1.00.
Because settlement happens once, the per-call cost collapses — a $1 package of 1,000 calls pays the protocol's fixed component once instead of a thousand times. That is how sub-cent API pricing works here.
The purchase response carries the key, once:
{
"fulfillment": { "payload": … },
"package_key": "pk_x7Kd…",
"deliveries_remaining": 999
}
Spend it with any parameters you like:
curl "https://api.mechawallet.com/c/chk_abc123?q=globex" \
-H "Authorization: Bearer pk_x7Kd…"
{ "fulfillment": { "payload": … }, "calls_remaining": 998 }
Notes that matter:
- The key is shown once. Only its hash is stored, so it cannot be re-issued. Lose it and you buy the package again.
- It is scoped to one checkout. A key for another offer gets
401, the same answer as an unknown key. - Parameters are free per call. This is why a package needs a key at all:
re-presenting
X-PAYMENTcan only ever repeat one query, because the params are bound into the signature. - A per-tool MCP checkout still only buys that tool. The key carries no
signature, so
mcp_toolis enforced on every call instead — otherwise 500 calls of a $0.01 tool would buy 500 calls of a $5 one. - Exhaustion is
402, withcalls_usedandcalls_includedin thehelpblock — buy again to continue. - A failed origin call is refunded to your allowance; you are not charged for a call that returned nothing.
Offers advertise their size, so a package and a single call are distinguishable before purchase:
{ "id": "chk_abc123", "price": {"amount": "10.00"}, "calls_included": 1000 }
Package terms are in the 402#
An agent decides from this block, so the terms of a package are in it before anything is signed:
"endpoint": { "package": { "calls": 500, "expires_in_days": 30 } }
Entitlements expire 30 days after settlement by default; set
package_ttl_days on the checkout to change it. Keys minted before expiry
existed carry expires_at: null and never expire. An expired key is a 410,
not a 401 — it has already proved it exists, and its holder needs to know which
of the two happened.
Failure, and who is charged#
| Origin returns | Charged | HTTP to the buyer |
|---|---|---|
5xx, timeout, unreachable |
no | 502 |
401, 403, 429 — the seller's credential or capacity |
no | 502 |
400, 404, 422 — the buyer's request |
yes | the origin's own status |
Failure bodies carry "fault": "seller" \| "buyer" \| "platform" and a
help.report block naming where to report the problem.
GET /c/{id}/usage#
Free, spends nothing, Authorization: Bearer pk_….
{ "calls_included": 500, "calls_used": 2, "calls_remaining": 498,
"expires_at": "2026-08-31T…Z", "receipt": "/r/pay_…",
"calls": [ { "seq": 1, "at": "…", "status": "delivered",
"counted": true, "fault": null, "latency_ms": 39, "bytes": 64 } ],
"burn": { "calls_per_day": 12.4, "projected_days_remaining": 21.3,
"runs_out_first": "expiry", "basis": "trailing_7d" } }
counted says whether a call cost you; fault says why it did not.
runs_out_first is "calls" or "expiry" — pacing against the wrong one loses
the balance you were preserving. burn is null with
basis: "insufficient_usage" until there is enough evidence to project from.
Spending is rate-limited (60/min per key by default); responses carry
X-RateLimit-* so a caller can self-throttle rather than discover a 429.
Reports#
A buyer who paid and got nothing can say so, with no account. Settlement is non-custodial, so this cannot produce a refund — it makes the problem visible to the seller and to us, with our own delivery records attached.
POST /v1/checkouts/{id}/reports |
Public. {category, detail?, receipt_id?} |
GET /v1/checkouts/{id}/reports, GET /v1/reports |
Seller. Carries the joined delivery rows |
PATCH /v1/reports/{id} |
Seller acknowledges/resolves with a note |
There is no DELETE — a record of a complaint is not the seller's to erase,
same rule as receipts. Supplying a receipt_id or package key files it as a
buyer report with the payment attached; without one it is anonymous and rate
limited harder. Categories are a closed set so they aggregate:
not_delivered, wrong_data, stale_data, schema_changed,
shape_not_as_described, too_slow, unauthorized_charge, suspected_fraud,
other.
Sellers can subscribe to the checkout.reported webhook.
Liabilities#
GET /v1/analytics carries a liabilities block: unspent calls across every
live package key, per checkout, valued from provider_amount.
"liabilities": { "live_keys": 3, "calls_unspent": 1240,
"unearned": { "amount": "62.00", "currency": "USD" },
"offers": [ … ] }
A package is cash received against calls not yet served, so revenue and this
belong on one screen. Revoked and expired entitlements are excluded — neither is
still a claim. DELETE /v1/checkouts/{id} returns the same figure rather than
letting a seller retire something and forget they still owe it; it never blocks
the retirement.
Receipts#
Receipts are minted by settlement — there is no create endpoint, by design: a receipt that could be POSTed into existence could not prove anything. Every settled payment gets one automatically; on escrow checkouts it appears at release (a deposit is refundable money, not a sale).
parties (optional, on any create): platform-declared identities —
{"buyer": {"name","email","ref"}, "seller": {"name","ref"}} — rendered on
the receipt page and download ("Billed to …", declared seller name), echoed
verbatim in checkout.paid and receipt rows, flattened to
buyer_name/buyer_ref in the CSV export. Opaque to mechawallet: you
assert, we carry, the chain proves. Appears on the (unguessable, public)
receipt page — share its URL accordingly.
For platforms: GET /v1/receipts rows carry sold_by, and
?sold_by=<seller> returns one sub-seller's statement; the CSV export
carries the column. The checkout.paid webhook payload includes
receipt_url and sold_by, so a backend relays the receipt and routes the
sale with nothing derived and nothing joined. Sub-sellers have no accounts to
pull receipts with — the platform distributes; the public /r/{payment_id}
page is the artifact to hand onward.
GET /r/{payment_id}#
JSON or HTML. Contains payer, seller, network, the exact split, tx_hash, and delivery outcome.
subject is a stable pseudonym for the buyer, scoped to you. The same wallet
buying from you again produces the same subject, so you can recognise a repeat
customer without asking anyone to register — but the same wallet buying from a
different seller produces a different one, so nobody can correlate your buyers
against theirs. It is derived, not reversible to a wallet address.
Buyers are anonymous by default and nothing here changes that: subject is an
identifier we compute, not an account anyone created.
Recovery, files, and the work queue#
GET /r/{payment_id}/download |
The receipt as a keepable file. Public like the page — the id is the capability, and a buyer with no account still needs proof of what they paid for |
GET /r/by-tx/{tx_hash} |
Recover a lost receipt from its transaction hash, view-only. The durable trail for a throwaway wallet: exchange withdrawal history names the wallet → the chain names the tx → this names the purchase |
GET /v1/receipts/export |
Every settled payment, as CSV — carries sold_by and the flattened parties columns for sub-seller subtotals |
GET /v1/receipts/awaiting-fulfilment |
Sales you were paid for and have not closed out — the work queue that webhook mode implies. Mark done with the fulfilment call your flow already uses |
{
"id": "pay_dVo7SDQsatf4HIFC",
"checkout_id": "chk_abc123",
"network": "eip155:8453",
"amount_usd": "0.100000",
"provider_amount": 100000,
"fee_amount": 10500,
"total": 110500,
"payer": "0x7099...",
"subject": "sub_9f2c41ab7e05d3b8c6a1f402",
"pay_to": "0xSeller...",
"tx_hash": "d84fa280...",
"test_mode": false,
"settled_at": "2026-07-28T08:15:54Z"
}
Keys#
Acting for an organization (X-Mechawallet-Account), a key minted at POST /v1/keys belongs to the member who minted it and is capped at that member's role: it can do what they can do and no more. GET /v1/keys shows a member the keys they minted, and shows the owner every key with minted_by and role_ceiling; DELETE /v1/keys/{id} lets a member revoke their own and the owner revoke any. On a person's own account both fields are null.
POST /v1/keys |
Mint one. {name, mode, scope}. Shown once. mode defaults to test |
GET /v1/keys |
List (prefixes only, never the secret) |
DELETE /v1/keys/{id} |
Revoke immediately |
Scopes escalate read_only < payments < full.
What each scope may do#
| Scope | Can | Cannot |
|---|---|---|
read_only |
List and read checkouts, receipts, payments, analytics, usage | Create anything, move money, change account settings |
payments |
Everything above, plus create and update checkouts, take checkouts live, mark a receipt fulfilled | Change payout wallets, manage webhooks or keys |
full |
Everything | — |
Endpoints that move money or change where it goes require more than
read_only, and say so:
| Scope | |
|---|---|
PUT /v1/account/wallets |
full |
POST /v1/webhooks |
full |
DELETE /v1/webhooks/{id} |
full |
POST /v1/receipts/{id}/fulfilled |
payments |
POST /v1/account/wallets/preview |
read_only |
A key below the required scope gets 403 naming the scope it needed — not a
401, because the key is valid; it is the authority that is missing.
Changed — a read_only key used to be enough
These four endpoints previously accepted any valid key, because the scope
was resolved and then discarded. A read_only key — the one you hand out
believing it is harmless — could repoint the account's default payout
wallet. If you are driving any of the above with a read_only key, mint a
key with the scope in the table; nothing else changes.
An API key cannot mint a browser session
POST /auth/session/cookie now refuses automation keys with 403. A
session cookie carries full account authority no matter which key asked for
one, so a read_only key could otherwise trade itself for a full one in a
single request. Sessions obtained from a magic link — including the bearer
session token the dashboard holds — are unaffected.
Scopes are not a sandbox for untrusted code: a key you would not want used to
create a checkout should be read_only, and a key you would not want at all
should be revoked. Pick the lowest scope that does the job.
Spending wallets#
The rail an agent buys with. Custodial, and the only custody in the
service: mechawallet generates the wallet, holds its key and phrase encrypted,
and signs with it when an agent pays an x402 challenge. Its own namespace,
never under /v1/account/wallets, which is where you get paid.
An account holds any number of wallets, each in a branch: Main, which
every account has, or one you open for a job. {wallet} names one by its
id, its label, its address, or a chain name (base, solana) when the
account has one wallet on that chain; with several, the answer is 409 and
lists them. {branch} is a branch id or name.
| Who may call | ||
|---|---|---|
GET /v1/spending-wallets/health |
nobody | Public self-check: does the schema answer, does the encryption key load, do the generators import. Names what is wrong |
GET /v1/spending-wallets |
key or session | The active wallets (id, label, chain, address, branch, burn address), the branches, and account (slug, name, kind): whose wallets these are, so a member acting for an organization never has to guess. Never reads the chain |
GET /v1/spending-wallets/archive |
key or session | Burned and revoked wallets, burned branches |
GET /v1/spending-wallets/stats |
key or session | Spent, payments, fees, last paid: per wallet, per branch, and the account total, from the event stream |
GET /v1/spending-wallets/{wallet}/stats |
key or session | One wallet's numbers |
GET /v1/spending-wallets/{wallet}/events.csv |
key or session | The wallet's stream as CSV |
GET /v1/costs |
nobody | The price list: wallets, payments, funding, transfers, burn, as numbers and sentences, with the date it took effect |
GET /v1/spending-wallets/costs |
key or session | The price list plus this account's service history: every fee charged and collected, monthly totals, a CSV link |
GET /v1/spending-wallets/costs.csv |
key or session | The same history as CSV |
POST /v1/spending-wallets |
session only | Open one: {chain?, branch?, label?, acknowledge?}. Returns the wallet's facts, its key (raw shown here and again through /key/reveal) and secret (phrase + private key); everything is stored encrypted |
POST /v1/spending-wallets/{wallet}/key/reveal |
session only | The wallet's key, again. Recorded as key_revealed |
POST /v1/spending-wallets/{wallet}/key/rotate |
session only | A new key; the old one is dead at once |
DELETE /v1/spending-wallets/{wallet}/key |
session only | Kill the key without minting another |
GET /v1/spending-wallets/{wallet} |
key or session | One wallet's facts, without the chain |
PATCH /v1/spending-wallets/{wallet} |
session only | {label?, burn_address?, clear_burn_address?}: rename it, or give it its own burn address |
GET /v1/spending-wallets/{wallet}/balance |
key or session | Reads the chain. nagging: true plus a warning above $999 |
POST /v1/spending-wallets/{wallet}/pay |
live key, OAuth or session | {url, max_usd?, params?, method?, body?}. Pays any x402 URL up to what the wallet holds (a max_usd caps this one call) and returns {paid, url, receipt, goods, fee}. A checkout's /test URL is a free rehearsal that needs no funds |
GET /v1/spending-wallets/{wallet}/budget |
key or session | The facts an agent reads first: the balance is the budget, the fee rule, the free payments left, the onboarding state |
GET /v1/spending-wallets/{wallet}/funding |
session only | ?amount=5. Everything needed to send USDC here: checkout (the wallet's Send money to link for this amount, made on first ask; the sender pays the amount plus $0.01), page (the public page), and the plain way: uri (EIP-681 or Solana Pay), links per wallet app, the address |
POST /v1/spending-wallets/{wallet}/transfer |
live key, OAuth or session | {to, amount_usd}: move money to another of your wallets on the same chain by paying its funding link. $0.01, recorded as a transfer, never a purchase |
GET /fund/{address} |
nobody | The wallet's public Send money to page: its funding links as buttons and the plain address with a QR. JSON on request. Rate limited |
GET /v1/spending-wallets/{wallet}/funding.svg |
session only | ?amount=5. The plain payment request as a QR |
POST /v1/spending-wallets/keys |
session only | A live key for an agent, shown once, without the payout-wallet rule |
GET /v1/rehearsal-checkout |
key or session | A checkout whose /test face any agent can pay for free: {url, ask} |
POST /v1/spending-wallets/{wallet}/rehearse |
session | Try it free from the dashboard: the wallet pays that /test face itself and returns the receipt with ask |
POST /v1/spending-wallets/{wallet}/move |
session, manager+ | Move the wallet to another branch: {branch, dry_run?}. A dry run returns changes (branch keys, inherited rules, ranked order, where the change goes) without moving; the move returns the same list. Nothing on chain moves |
GET /v1/spending-wallets/{wallet}/policy |
key or session | The wallet's own policy, the branch's it inherits, and the effective one: allow_hosts, max_per_call_usd, approve_over_usd |
PUT /v1/spending-wallets/{wallet}/policy |
session, manager+ | Set the wallet's policy, whole; {} clears it. A key can never loosen it |
POST /v1/spending-wallets/{wallet}/reveal |
session only | The private key and the recovery phrase. Recorded as revealed |
POST /v1/spending-wallets/{wallet}/burn |
session only | {to?}. Send what is left to the burn address (wallet, then branch, then account; or to for this burn only) through a return checkout (only the one-cent settlement), record burned, close the wallet. Refused with the step when no burn address is set |
DELETE /v1/spending-wallets/{wallet} |
session only | Archive it without moving money. Funds are untouched; burn, or reveal first |
PUT /v1/spending-wallets/burn-address |
session only | {chain, address} (null clears): the account's default burn address for a chain |
POST /v1/spending-wallets/acknowledge |
session only | Step zero on its own; POST /v1/spending-wallets with {"acknowledge": true} does the same in one call |
PUT /v1/spending-wallets/profile |
session only | {archive_public}: whether burned wallets show on the public page |
GET /v1/spending-wallets/rehearsal |
anybody | Points at GET /v1/rehearsal-checkout, the rehearsal checkout whose /test URL is payable from an empty wallet |
GET /v1/spending-wallets/{wallet}/events |
key or session | created, funded, spent, refused, revealed, flagged, burned, revoked, with the actor |
Branches#
| Who may call | ||
|---|---|---|
GET /v1/spending-branches |
key or session | Main first, then the branches you opened: name, rule, wallets, cap |
POST /v1/spending-branches |
session only | {name, rule?, burn_address?}. Rules: lifo (default), fifo, ranked |
POST /v1/spending-branches/{branch}/key/reveal |
session only | The branch's key, again. Main has none |
POST /v1/spending-branches/{branch}/key/rotate |
session only | A new branch key; the old one is dead at once |
DELETE /v1/spending-branches/{branch}/key |
session only | Kill the branch key |
GET /v1/spending-branches/{branch} |
key or session | One branch with its wallets |
PATCH /v1/spending-branches/{branch} |
session only | {name?, rule?, order?, burn_address?, clear_burn_address?}. order is wallet ids, first pays first (ranked). Main takes only a burn address |
GET /v1/spending-branches/{branch}/balance |
key or session | Every wallet's balance and the total, read from the chain |
GET /v1/spending-branches/{branch}/events |
key or session | The branch's rows and its wallets' rows, newest first |
GET /v1/spending-branches/{branch}/stats |
key or session | The branch's wallets' numbers and their total |
POST /v1/spending-branches/{branch}/pay |
live key, OAuth or session | Same body as a wallet's /pay, plus chain?. The branch's rule picks the wallet; the answer names it. Main does not pay as a branch |
POST /v1/spending-branches/{branch}/burn |
session only | Burn every wallet in the branch, then the branch. Refused before anything moves when a wallet has no burn address |
A made branch holds at most ten active wallets, because paying through it may read every balance in it before choosing. Main has no rule, no key and no cap.
Wallet keys and branch keys#
Every wallet gets its own key when it is opened (mw_wallet_…), and every
branch but Main gets one too (mw_branch_…). They authenticate exactly like
an account key, on /v1/… and on /mcp, and they are bound: a wallet key
reads and pays from its wallet and nothing else; a branch key pays through
its branch's rule (or from a wallet in the branch), reads those wallets, and
may open more wallets in its branch under the cap. Neither can open a
checkout, mint a key, touch a burn address, reveal anything, open or burn a
branch, or see the rest of the account; on this rail they carry the
read_only scope. A signed-in person can read a key again at any time,
rotate it (the old one dies at once) or revoke it; the API and MCP never
return one. The event stream names a key by its prefix.
Costs#
Every price lives in one policy (GET /v1/costs) and is rendered from it
everywhere. Your first three wallets are free; from the fourth on, $0.35 per
wallet, taken from the wallet itself the first time it holds $1.00. A wallet's
key spends once the wallet has held $1.00 (before that it receives money and
rehearses; a payment answers 402 insufficient balance). 1,000 payments a
month are free, then $0.002 per payment to an outside endpoint. Funding a
wallet through its checkout and moving money between your wallets cost $0.01
each. Burning is free. Fees come out of the wallet they concern and are
collected on-chain by the sweep; fees_owed_usd on the balance is what is
still to be collected, and it is never spendable.
Two authorities, kept apart on purpose. Spending takes any credential that
is not a test key, because the point is that an agent buys without a human,
and the dashboard offers the same call so a person can check a freshly funded
wallet actually pays. Revealing, burning and every address that decides where
money goes take a browser session and nothing else: a request that carries an
API key is refused with 403 even when a valid cookie rides along, no OAuth
token reaches it, and no MCP tool exposes it. The credential that spends must
not be the credential that exfiltrates, or every max_usd ceiling is decorative.
The ceiling on a payment is the wallet's spendable balance, lowered only by
the agent's max_usd for that call. A mw_test_ key
cannot pay (403): the money is real and someone else's endpoint has no
rehearsal face. The balance is read from the chain before anything is signed;
a short wallet answers 402 with insufficient balance and spends no gas.
Every refusal is recorded as a refused event with its reason.
There is no transfer-out endpoint to an arbitrary address beyond a burn, and none will be added. A holder who wants their funds elsewhere burns the wallet to a burn address they set, or reveals the key and moves them. See the spending wallets guide.
An agent's own signup#
| Method and path | What it does |
|---|---|
POST /v1/spending-wallets/signup {chain?, label?} |
No credential. Opens an account and one wallet for the agent itself; 201 with the wallet, its key (mw_wallet_…) and a funding page. Rate-limited per address. Until claimed the account holds one wallet and can receive and pay; nothing takes money out |
POST /v1/spending-wallets/claim {email} |
With the wallet's key: emails the person a claim link (/login?claim=…) |
POST /v1/orgs/claim {token} |
Signed in as the person the claim went to: they become the owner; the account appears in their account switcher, their own account untouched |
Statements#
One numbered document per calendar month, MW-<YYYYMM>-<account>: every payment, fee, opening fee, funding, transfer and burn in that month, each with wallet, detail and receipt, and the totals. A record of what money did, not a bill: fees came out of the wallet they concern at the time. Acting for an organization returns the organization's.
| Method and path | What it does |
|---|---|
GET /v1/spending-wallets/statements |
The months with activity, newest first, each with its totals, number and address |
GET /v1/spending-wallets/statements/{month} |
One month (2026-09) itemised, with csv and print links; each row carries by_label (who did it) and the month by_who (spent per person and key) |
GET /v1/spending-wallets/statements/{month}.csv |
The same as CSV |
GET /v1/spending-wallets/statements/{month}/print |
A printable page; save it as a PDF from the browser |
Organizations#
An organization is an account several people act for. It has its own
handle, wallets, keys and public page, and no sign-in of its own: people sign
in as themselves and act for it with the header X-Mechawallet-Account:
<handle> (or ?account= on a link, or account on any MCP tool). Roles:
spender pays and reads; manager also opens and burns wallets and
branches and handles keys; owner also invites, removes, transfers, sets
burn addresses, reveals private keys, and opens the organization's first
wallet. Exactly one owner; ownership is transferred, never invited.
| Who may call | ||
|---|---|---|
POST /v1/orgs |
session only | {name, slug?}: found one. You own it |
GET /v1/orgs |
key or session | The organizations you belong to, with your role |
GET /v1/orgs/{org} |
member | Members and your role; pending invitations for the owner |
POST /v1/orgs/{org}/invitations |
session, owner | {email, role}: an email carries the acceptance link, good for seven days |
GET /v1/orgs/{org}/invitations |
session, owner | Pending invitations |
DELETE /v1/orgs/{org}/invitations/{id} |
session, owner | Withdraw one |
POST /v1/orgs/accept |
session only | {token}: the invited address, signed in, joins at the invited role |
PATCH /v1/orgs/{org}/members/{member} |
session, owner | {role}: manager or spender |
DELETE /v1/orgs/{org}/members/{member} |
session, owner | Remove a member (never the owner) |
POST /v1/orgs/{org}/transfer |
session, owner | {member_id}: hand the organization over; you stay as a manager |
GET /auth/me reports account_kind, your memberships, and, with the
header, the organization and your role under acting.
Support#
A thread per question on the account, answered by a person in the thread and by email. Any of the account's credentials may write here: a session, an account key, a wallet key or a branch key. Acting for an organization (X-Mechawallet-Account) opens the thread on the organization; the reply is emailed to the member who opened it. Twenty open threads per account; subject 3 to 120 characters, body up to 4000. The same actions are the MCP tools support_open_thread, support_threads and support_reply.
| Method and path | What it does |
|---|---|
POST /v1/support/threads {subject, body} |
Opens a thread; 201 with the thread and its first message |
GET /v1/support/threads?status= |
The account's threads, newest activity first; open means the account wrote last, answered a person wrote back, closed |
GET /v1/support/threads/{thread} |
One thread with its messages; {thread} is the id or its first eight characters |
POST /v1/support/threads/{thread}/messages {body} |
Writes in it and marks it open again; 409 on a closed thread |
POST /v1/support/threads/{thread}/close |
Closes it |
Origin keys#
The seller's OWN upstream credentials, so an origin-backed checkout (relay, file or mcp) can wrap something
that answers 401. Distinct from /v1/keys, which authenticate you to us.
POST /v1/origin-keys |
{auth_type, value, name?, header_name?} → stored encrypted |
GET /v1/origin-keys |
list, with checkouts_using per key |
PATCH /v1/origin-keys/{id} |
{value} — rotates everywhere at once |
DELETE /v1/origin-keys/{id} |
revoke (soft; warns if checkouts still use it) |
auth_type is bearer | basic | api_key | custom; the last two need
header_name.
Reference one with origin_auth_secret_id from any origin-backed checkout —
relay or mcp — or pass origin_auth_value to store a one-off
encrypted copy. POST /v1/mcp/tools accepts either.
The value is never returned
Not on create, not on list, not on rotate. There is no read path — it is
decrypted only inside the relay, on the way out to your origin. A credential
belonging to another account returns 404.
MCP servers you sell#
POST /v1/mcp/tools#
Ask a seller's own MCP server what tools it advertises. Seller-authenticated
(payments scope), because it makes an outbound request to a URL the caller
chooses.
{ "origin_url": "https://mcp.yourco.com/mcp" }
Optional: origin_auth_type, origin_auth_value, origin_auth_header, or
origin_auth_secret_id to reuse a stored origin key. Sending the credential the
checkout will use is the point — a server that 401s the probe would 401 a
buyer.
Returns { origin_url, tools: [{ name, description, input_schema }] }.
502 if the server cannot be reached or refuses the handshake.
Nothing is stored
This is not a catalogue. The answer is not saved, not published, and exists
so a mcp_tool is a name the server actually has. A guessed name creates a
checkout that takes the money and then fails on a tool the origin never had.
Calling a paid tool#
The buyer's parameters become the tool's arguments. On a whole-server checkout
tool=<name> picks the tool; on a per-tool checkout the name is fixed and a
different tool= is refused. Parameters are strings because they are signed, so
a tool needing numbers or nesting takes arguments as a JSON object encoded
as a string:
GET /c/chk_abc123?tool=search_companies&arguments={"limit":5}
origin_method is forced to POST: MCP over Streamable HTTP is JSON-RPC over
POST. The initialize handshake runs before every call, and replies are read
from application/json or text/event-stream.
Discovery#
How a machine that has never been here finds out what is purchasable — public and unauthenticated by design, because the agent that most needs a catalogue is the one without a key.
GET /.well-known/mechawallet |
This service: capabilities, rails, links |
GET /v1/sellers/{slug}/offers |
One seller's catalogue, machine-readable |
GET /s/{slug} |
The same catalogue, human face |
GET /v1/sellers/{slug}/embed |
The blocks for the seller's own site: store (the live storefront div + script — paste this), html (static JSON-LD + link list alone), well_known (for /.well-known/mechawallet) |
Webhooks#
POST /v1/webhooks |
Register — or re-key: the same url again returns a fresh secret for the same endpoint (rekeyed: true), old secret dead from the next delivery. Run it per deploy; it is also secret recovery and revocation |
GET /v1/webhooks |
List, with failure counts (never the secret) |
GET /v1/webhooks/verification-key |
Public, no auth: the Ed25519 key that verifies every delivery's X-Mechawallet-Ed25519 header — receivers that use it need no stored webhook secret |
DELETE /v1/webhooks/{id} |
Remove a receiver; its pending deliveries go with it |
See Webhooks for signature verification.
Auth and profile#
POST /auth/magic-link/request |
{email} → emails a sign-in link |
GET /auth/verify?token= |
Consumes the link, sets the session cookie |
POST /auth/wallet/challenge |
{address, network} → a message to sign |
POST /auth/wallet/verify |
{message, signature} → account + first key |
GET /auth/me |
Your full profile |
PATCH /auth/me |
Update name, bio, slug, website_url, avatar_url, profile_type, social_links |
POST /auth/logout |
Clears the session |
Empty string clears a profile field; omitting it leaves it unchanged. A taken slug returns 409.
Activity log#
GET /v1/account/events |
Your account's state changes, newest first |
Owner-scoped: you see your own timeline and nobody else's. limit (default
50, max 200), action as a prefix filter (checkout. is the whole domain),
before as a cursor (an event id or an ISO timestamp). Each row:
{id, action, object_id, detail, actor, test_mode, at} — actor names the
credential (session, key:<prefix>, oauth:<client>, wallet). Secrets
never appear; what is and is not recorded is spelled out in
the records guide.
Facilitator#
x402 facilitator endpoints, one set per family:
/x402/base/{supported,verify,settle}
/x402/solana/{supported,verify,settle}
/x402/stellar/{supported,verify,settle}
Errors#
Errors are RFC 9457 problem documents,
served as application/problem+json. Branch on type — it is a stable URI, and
unlike a prose message it will not change wording under you.
{
"type": "https://mechawallet.com/problems/payment-required",
"title": "Payment required",
"status": 402,
"detail": { "message": "invalid payment: signature does not match payer", "help": { … } },
"help": {
"fix": "re-read the 402 and sign the CURRENT requirement",
"prepare": "POST /c/chk_.../prepare {\"payer\": \"0x...\"}",
"retry_is_safe": "re-presenting the SAME X-PAYMENT redelivers; it never charges twice"
}
}
The help block is the useful part: it names the fix, not just what went wrong.
It is available both at the top level and inside detail, which is preserved
unchanged for existing clients.
type |
Status | Meaning |
|---|---|---|
/problems/unauthenticated |
401 | No valid key or session |
/problems/payment-required |
402 | The payment was presented and rejected |
/problems/forbidden |
403 | Authenticated, but not yours |
/problems/not-found |
404 | No such checkout, receipt or key |
/problems/conflict |
409 | An Idempotency-Key request is still in flight |
/problems/invalid-request |
422 | Validation failed; see errors[] for the fields |
/problems/upstream-failed |
502 | The seller's origin failed after payment |
/problems/unavailable |
503 | No settleable network right now |
The 402 is not an error#
GET /c/{id} answering 402 is the protocol working, not a failure, so it is
not a problem document. Its body is the x402 payment-required shape with
accepts[], and the same requirements are in the PAYMENT-REQUIRED header. Pay
and retry — see Buy as an agent.
Validation errors name the field#
{
"type": "https://mechawallet.com/problems/invalid-request",
"status": 422,
"detail": { "message": "request failed validation" },
"errors": [{ "field": "price_usd", "reason": "Field required" }]
}