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 toPOST /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:
- Your account sets what is allowed (
escrow_policy:disabled,seller_choice,always). - Each checkout sets what it offers (
escrow_offer). - 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):
POST /e/{id}/preparewith{"payer": "0xYou", "network": "eip155:8453"}→ the response carriestyped_data(sign this, EIP-712) andpayload_stub(echo this back verbatim — it includespayerSalt, from which the escrow's payment id is derived; strip a field and verification fails loudly).- Sign
typed_datawith the paying wallet (eth_signTypedData_v4or local). POST /e/{id}/paywith headerX-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:
POST /e/{id}/preparewith{"payer": "YourPubkey", "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"}→ a partialtransaction(base64),payer_signer_index(your slot), andpayer_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.- Sign your slot; leave slot 0 (the fee payer) empty.
POST /e/{id}/paywithX-PAYMENT: base64url({"network": "solana:…", "payload": {"transaction": "<signed base64>", "payer": "YourPubkey", "payer_salt": "<from prepare>"}}). Omittingpayer_saltfails — 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:
- Pay it:
POST /e/{id}/test/paywithX-PAYMENT: base64url({"network": "eip155:84532", "payload": {"payer": "0xAnyAddress"}})— no signature needed on the bare test shape. →held, with the fast deadlines. - 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 rehearsecancel; owners rehearse rulings onPOST /v1/escrows/{payment_id}/resolve?to_seller=…the same way. - Or do nothing: the production watcher auto-releases it ~60s after deposit.
- Every transition fires the SAME webhooks as production (
test_mode: truein the payload), includingcheckout.paidat 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.