mechawallet_
Sell

Escrow#

Hold the buyer's money until they're happy, without holding it yourself.

A normal checkout settles instantly: the moment a buyer pays, the money is the seller's and nothing can reverse it. That is the right default for most things. It is the wrong default when the buyer is paying a stranger, or paying before they can check what they got.

An escrow checkout pays into a contract instead. The money is held, releases to the seller automatically after a window, and the buyer can pause that release while they check. Only two outcomes are ever possible, and both are fixed the moment the buyer signs: the seller is paid, or the buyer is refunded. No key — not the seller's, not the buyer's, not ours — can send it anywhere else.

What it costs

Escrow is 1% + $0.02 instead of the standard 0.5% + $0.01 — the extra cent is arithmetic, not margin: an escrow lands a deposit AND a release, plus any relayed actions we pay for so the buyer never needs gas. The buyer sees the total before signing, so choosing protection is a priced, visible choice.

Create one#

curl -X POST https://api.mechawallet.com/v1/escrow-checkouts \
  -H "Authorization: Bearer $MW_KEY" \
  -H "content-type: application/json" \
  -d '{
    "price_usd": "120.00",
    "title": "Repo transfer: acme/parser",
    "origin_url": "https://yourco.com/deliver",
    "escrow_preset": "standard"
  }'
{
  "id": "esc_7Kd2mQ...",
  "url": "https://api.mechawallet.com/e/esc_7Kd2mQ...",
  "escrow": {
    "mode": "escrow",
    "settle_window": 259200,
    "hold_window": 259200,
    "backstop_window": 2592000,
    "arbiter_evm": "0x3364...27Bc",
    "summary": "Your payment is held by an escrow contract, not sent to the seller..."
  }
}

Everything POST /v1/checkouts accepts works here too — fulfillment modes, packages, platform fees, offer cards. The only additions are the terms of the hold.

The three windows#

Window What it is
settle How long the buyer has to intervene before the seller is paid automatically. This is the one that matters most — it is the default outcome.
hold If the buyer pauses, how long they have to approve or dispute before an arbiter may step in.
backstop The outer bound. A dispute that is never resolved refunds the buyer automatically after this. Money can never be stranded.

Pick a preset instead of thinking in seconds:

Preset settle / hold / backstop Use for
instant 24h / 72h / 14d API access, downloads — the buyer knows immediately
standard (default) 3d / 3d / 30d Repos, most marketplace goods
shipping 14d / 7d / 60d Physical goods in transit

Override any of them per checkout with escrow_settle_window, escrow_hold_window, escrow_backstop_window (seconds). Limits: each window is between 1 hour and 30 days, the backstop may run to 180 days, and the backstop must be longer than the hold.

Terms are frozen at create

The windows and the arbiter go inside the buyer's signature. Changing your account defaults later does not change a checkout that already exists — that would be changing a deal someone already agreed to.

What the buyer experiences#

They pay once, exactly like a normal checkout. Then:

  • Do nothing — the money releases to the seller when the settle window ends. This is what happens most of the time, and it needs no action from anyone.
  • Approve — release immediately, as soon as they're happy.
  • Pause — stop the clock while they check, then approve or ask for a refund.

The seller never signs anything. Money arrives on its own.

Disputes, and who decides#

If a buyer pauses and then asks for a refund, an arbiter decides between the two committed routes. That's the only power an arbiter has: it cannot send the money to a third place, change the amount, or take a cut.

Selling your own products? The arbiter is operated by mechawallet by default, so escrow works with zero setup.

Running a marketplace (platform account)? Then your marketplace's disputes are yours to judge — mechawallet never adjudicates a checkout you minted for one of your sub-sellers. Name your arbiter wallets before offering escrow:

{ "escrow_arbiter_evm": "0xYourWallet...",
  "escrow_arbiter_svm": "YourSolanaWallet..." }

Set them once on your account (PUT /v1/account/escrow, or the dashboard's Who settles disputes), or per create. Offering escrow on a sub-seller checkout with no arbiter of your own is refused with a 400 — and a chain you name no arbiter for simply won't offer escrow on those checkouts, because an escrow whose disputes nobody can judge is not one to sell. (The buyer stays safe either way: an undecided dispute refunds automatically at the backstop.)

Ruling on a dispute#

Escalated escrows are in GET /v1/escrows?status=escalated and in your dashboard's Live escrows list, which shows Pay seller / Refund buyer buttons on disputes your arbiter wallet owns — on both chains. Base signs with your injected EVM wallet; Solana signs with any Wallet-Standard wallet (Phantom, Solflare, Backpack) and broadcasts on mechawallet's gas, so your arbiter wallet needs no SOL.

Programmatically, POST /v1/escrows/{payment_id}/resolve?to_seller=… returns an unsigned transaction for your arbiter wallet — mechawallet never holds that key, which is what makes "you adjudicate" true rather than a promise:

  • Base (family: "evm"): sign it with the arbiter wallet and broadcast it yourself; gas is sub-cent.
  • Solana (family: "svm"): sign your slot (authority_signer_index) and hand it back to POST /v1/escrows/{payment_id}/resolve/submit — mechawallet co-signs the fee and broadcasts, so your arbiter wallet needs no SOL.

The same pair exists as MCP tools (resolve_escrow, resolve_escrow_submit) for an agent working your dispute queue.

Offering protection on a normal checkout#

You don't have to choose for your buyers. A plain checkout can advertise protection as an option:

{ "escrow_offer": "optional" }

none (default) keeps today's behaviour, optional lets the buyer choose and pay the escrow rate if they want it, required means only the protected face exists.

There is a consent chain, and nobody's half is assumed:

  1. Your account sets what is allowed (escrow_policy: disabled, seller_choice, always).
  2. Each checkout sets what it offers (escrow_offer).
  3. Each buyer chooses at pay time.

That matters because escrow costs each party something different — the buyer the escrow rate (1% + $0.02, replacing the standard fee, not added to it), the seller held cash and dispute exposure, a platform its arbitration duty.

Webhooks#

Subscribe to the escrow events to follow the hold:

Event Means
escrow.held Deposited. The clock to auto-release is running.
escrow.on_hold The buyer paused it.
escrow.escalated The buyer asked for a refund; the arbiter may act.
escrow.released The seller (and platform) were paid.
escrow.refunded The held money went back to the buyer.

checkout.paid still means what it always meant

On an escrow checkout, checkout.paid fires at release — not at deposit — because only then is the money really the seller's. If you already have a handler that credits a seller when checkout.paid arrives, it keeps working with no changes and cannot credit a sale that later refunds.

For the same reason, an escrowed payment does not appear in /v1/receipts until it releases.

Register for them explicitly (existing endpoints are not auto-subscribed):

curl -X POST https://api.mechawallet.com/v1/webhooks \
  -H "Authorization: Bearer $MW_KEY" \
  -d '{"url": "https://yourco.com/hooks",
       "events": ["checkout.paid", "escrow.released", "escrow.refunded", "escrow.escalated"]}'

For agents#

An escrow checkout is still one URL and still speaks x402. GET it with Accept: application/json and the 402 challenge carries the full terms in accepts[].extra.escrow — windows, arbiter, both exit routes, and a plain sentence describing what happens if nothing is done. An agent can decide whether to pay knowing its recourse, before it signs anything.

payTo is the escrow contract rather than the seller, and the buyer's signature commits to every term above — so an altered window or a re-aimed arbiter makes the signature invalid rather than silently changing the deal.

Paying one, exactly#

Do not reuse the classic signing path. An escrow payment's nonce commits to the escrow terms, not the splitter split — a classic v1/v2 nonce produces a signature the escrow contract rejects, discovered only at pay time. Always go through /prepare, which computes the right nonce for the face you are paying:

Base (EVM):

  1. POST /e/{id}/prepare with {"payer": "0xYou", "network": "eip155:8453"} → the response carries typed_data (sign this, EIP-712) and payload_stub (echo this back verbatim — it includes payerSalt, from which the escrow's payment id is derived; strip a field and verification fails loudly).
  2. Sign typed_data with the paying wallet (eth_signTypedData_v4 or local).
  3. POST /e/{id}/pay with header X-PAYMENT: base64url({"network": "eip155:8453", "payload": {…payload_stub, "signature": {"v": 27|28, "r": "0x…32B", "s": "0x…32B"}}}) — the signature is the SPLIT object, never the flat 65-byte hex.

Solana:

  1. POST /e/{id}/prepare with {"payer": "YourPubkey", "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"} → a partial transaction (base64), payer_signer_index (your slot), and payer_salt — keep it, the payment id is derived from it. Your wallet also funds ~0.003 SOL of account rent, returned when the escrow closes.
  2. Sign your slot; leave slot 0 (the fee payer) empty.
  3. POST /e/{id}/pay with X-PAYMENT: base64url({"network": "solana:…", "payload": {"transaction": "<signed base64>", "payer": "YourPubkey", "payer_salt": "<from prepare>"}}). Omitting payer_salt fails — without it the server cannot re-derive the escrow being paid.

The success body's escrow.state is "held". Held is not paid: the receipt appears and checkout.paid fires only at release.

Rehearse the whole lifecycle first (test face)#

Every escrow checkout has a test face at /e/{id}/test — same protocol, simulated money, and a compressed clock: settle 60s, hold 120s, backstop 300s — so an agent can drive every path in one session:

  1. Pay it: POST /e/{id}/test/pay with X-PAYMENT: base64url({"network": "eip155:84532", "payload": {"payer": "0xAnyAddress"}}) — no signature needed on the bare test shape. → held, with the fast deadlines.
  2. Act on it: the SAME action endpoints (POST /e/{id}/actions/{hold|approve|request-refund}?payment_id=…) apply the transition INSTANTLY on a test payment and return {"simulated": true, "state": …} — no wallet, no transaction. Sellers rehearse cancel; owners rehearse rulings on POST /v1/escrows/{payment_id}/resolve?to_seller=… the same way.
  3. Or do nothing: the production watcher auto-releases it ~60s after deposit.
  4. Every transition fires the SAME webhooks as production (test_mode: true in the payload), including checkout.paid at release and never before — so the ledger logic you build against the rehearsal is the logic production needs.

Embedding#

A checkout can run inside your own page, so the buyer never leaves it:

<div data-mw="esc_abc123"></div>
<script src="https://api.mechawallet.com/static/embed.js" async></script>

That is the whole integration. Every attribute the script reads:

Attribute Meaning Default
data-mw the checkout id — esc_… renders the protected face at /e/, anything else the classic one at /c/ required
data-mw-test="true" embed the TEST face: same checkout, simulated money, compressed escrow clock — build against this first off
data-mw-mode="button" render a Buy button that links out instead of the in-page frame — zero config: a link needs no allowlist and no wallet_button full frame
data-mw-label the button's text (button mode only) Buy →
data-mw-height the frame's minimum height before the first mw:resize arrives 520px
data-mw-theme light, dark, or auto — the frame matches YOUR page's design instead of the visitor's OS. auto reads the page's own background around the mount visitor's OS
data-mw-accent one color (#RRGGBB) for the price and highlights inside the frame sea green
data-mw-format flat drops the card chrome — no background, border or shadow, so the checkout sits directly on your page's surface card
data-mw-radius corner radius in px (0–32) for the card and its elements, to match your page's corner language 16
data-mw-font system drops the brand faces for the platform's native font stack brand fonts
data-mw-content a gated article or dataset: mounts the paywall at /x/{id}, keeps the reader's pass on YOUR origin, and opens silently once they have paid
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-price the price shown on the lock panel, so it needs no round trip before painting from the snippet
data-mw-blurb your own wording for what one payment buys a sentence about the pass
data-mw-open set BY us on the mount once the reader is through, so your own CSS can react
data-mw-part a slice instead of the card: pay (the whole action module), wallet (desktop till alone), qr (the phone surface, pre-armed), agent (the x402 link block), price (display-only tag). Sibling frames of one checkout flip to paid together whole card
data-mw-receipt="pay_…" a RECEIPT in the frame — the buyer's proof and, on escrow, their pause/approve/dispute controls, inside your own order page. The id comes from the checkout.paid webhook (payment_id) or the mw:paid/mw:held event. Same allowlist as checkouts
data-mw-media a gated PHOTO, clip or recording: the seller's public preview stays, and the paid file replaces it once a pass is held
data-mw-kind what the media mount builds: image, video or audio image
data-mw-blur blur the preview by N pixels while locked. Presentation only — the original is never sent
data-mw-store a whole catalogue: every live checkout this seller lists, rendered natively in the host page; each item opens an ordinary checkout mount to pay
data-mw-layout storefront arrangement: grid (default) or rows grid
data-mw-limit storefront: show at most N items, with an "All N products" link after
data-mw-buy storefront buy behaviour: frame (in-page till, default) or link (open the checkout page) frame

Two chains never confuse a buyer. When a checkout offers both Base and Solana (same price on each, fees included), the buttons ask the only question that matters — where is your USDC? — reading "Pay with USDC on Base" / "Pay with USDC on Solana", never wallet brand names. The page detects the buyer's installed wallets and prints them under each button, leads with the only payable rail when just one family is installed (the other dims but never disappears), remembers the rail they last paid on, and an insufficient-funds failure points at the other button's separate pot of USDC instead of reading as an outage. All of this is the page's job — build nothing for it, in the frame or out.

The frame auto-sizes after load, and the buyer pays with the wallets they already have: on the EVM side any EIP-6963 wallet (MetaMask, Rabby, Coinbase Wallet, Brave, …) — buyers with several installed get a picker, and the choice sticks across the pay page, the receipt and the dashboard; on Solana any Wallet Standard wallet (Phantom, Solflare, Backpack). Legacy window.ethereum-only wallets still work as the fallback. On mobile, where extensions do not exist, a buyer with no injected wallet gets a deep link that reopens the page inside their wallet app's browser instead of advice to install an extension. And a desktop buyer with no extension gets the reverse: "Pay from your phone" on the pay page shows a QR of the checkout URL — scan, the same page opens on the phone, the wallet app takes it from there. No WalletConnect relay, no projectId, no session protocol: a one-shot payment needs a handoff, not a session, and this one works with every mobile wallet. The same QR is yours to place anywhere as a stable image — {checkout_url}/qr.svg (test face …/test/qr.svg) — invoices, packaging, posters; a camera scan opens the checkout.

Create the checkout with "wallet_button": true. That is what puts the pay buttons inside the frame; without it the embed renders as a read-only product page and buyers cannot pay without leaving your site. (It defaults off because a bare checkout URL is meant to be read by humans and paid by machines — an embed is exactly the case where the human pays in place.)

When something is wrong, the embed says so. A frame that cannot work — the id has a typo, or your origin is not allowlisted yet — is replaced with a plain Buy → link that opens the checkout directly, so a buyer never faces dead space; and the exact fix is printed in your browser console ([mw embed] …). A checkout that renders but cannot be paid in-frame gets a console warning naming wallet_button.

You must name your site first. Nothing may frame a checkout by default:

curl -X PUT https://api.mechawallet.com/v1/account/embed \
  -H "Authorization: Bearer $MW_KEY" \
  -d '{"embed_origins": ["https://shop.example", "http://localhost:5173"]}'

Send the whole list each time — it replaces what is there, so removing a site means sending the list without it. The dashboard has the same control under Put it on your website.

Until your origin is listed the frame stays blank. That is frame-ancestors working, not a bug: the page inside carries a button that moves money, and a page anyone may frame is a page anyone may frame invisibly, underneath their own fake one. Wildcards are refused for the same reason, and plain http is allowed only for localhost.

Listening#

The frame tells your page what happened:

window.addEventListener("mw:paid", function (e) {
  // e.detail = { checkout_id, payment_id, receipt_url }
  showThanks(e.detail.receipt_url);
});
Event Means e.detail fields
mw:ready the checkout rendered (if this never fires, check your origin is allowlisted) checkout_id
mw:paid the money is the seller's. On an escrow checkout this fires at release, not at deposit checkout_id, payment_id, receipt_url, settle_deadline: null
mw:held paid into escrow — held, and still refundable checkout_id, payment_id, receipt_url, settle_deadline (ISO time of the auto-release)
mw:on_hold the buyer paused the auto-release payment_id, tx
mw:released the buyer approved the release payment_id, tx
mw:escalated the buyer asked the arbiter to decide payment_id, tx
mw:resize the frame's content changed height (handled for you) height

Every e.detail also carries type (the event name), so one handler can fan out. Treat payment_id as the join key to your backend's webhook records — the frame events are UI signals; the webhook is your durable record, and the two carry the same payment_id.

Do not credit a sale on mw:held. That is money you do not have yet and an escrow can still refund it. mw:paid is the one that means the sale is final, on both kinds of checkout — which is why it is the same event name for both, and why it arrives later for an escrow.

There is no mw:refunded, and no frame event for a release that happens on the auto-release timer: both occur on-chain long after the frame is gone. Subscribe to the escrow.* webhooks for those. A frame is a UI; the webhook is your record.

If you would rather not use embed.js, write the <iframe> yourself and listen with window.addEventListener("message", …) — but check event.origin against https://api.mechawallet.com before you trust a single field. embed.js does that for you, which is most of what it is for.

Limits worth knowing#

  • Base and Solana (USDC), both live, and on both every post-payment action — pause, approve, dispute, ruling — is GASLESS: you sign, and mechawallet's relayer pays the fee. Escrow is recourse, and recourse a buyer cannot afford to exercise is not recourse; a wallet holding only USDC can use every control. (On Solana the buyer additionally funds ~0.003 SOL of account rent at deposit, returned when the escrow closes. Escrows created before 2026-08-26 sit on the older Base contract, where actions are self-broadcast at sub-cent gas.)
  • The protocol fee is taken at deposit and is not refunded, on either outcome. It pays for the escrow itself. The seller's and platform's legs refund in full.
  • A blocked recipient blocks its route. If a payout address is frozen by the token issuer, that exit cannot execute; screen recipients before you sell.
  • Escrow is single-shot; reusable payment links are not supported yet.