mechawallet_
Reference

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[].resource names /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 402 carries endpoint.rehearsal.test_url; a test face states in words that it settles without real money, plus live_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_cap on create to change it. Past the cap is a 429 with a live_url hint. 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 modedata-mw-mode="button" (+ data-mw-label) renders a Buy link instead of the frame: zero config, no allowlist, no wallet_button.
  • QR assetGET /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 (#RRGGBB for 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-media with data-mw-kind (a gated photo, clip or recording; data-mw-blur blurs 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 the checkout.paid webhook or the paid/held events), data-mw-store="slug" (the seller's whole catalogue, rendered natively; with data-mw-layout grid/rows, data-mw-limit N, and data-mw-buy frame/link for how an item's Buy behaves). SPAs that add mounts after load call window.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=250buyer_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-PAYMENT can 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_tool is enforced on every call instead — otherwise 500 calls of a $0.01 tool would buy 500 calls of a $5 one.
  • Exhaustion is 402, with calls_used and calls_included in the help block — 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" }]
}