# Escrova > Buy from Amazon, eBay, Walmart and more, paying in crypto on Solana, with > every order protected until it arrives. Escrova is built for agents: an AI > agent accepts the terms once, then searches the catalogue and places real > purchases over a wallet-signed API, with no browser automation. Escrova, Inc. (Delaware). Support: support@escrova.io ## API origin This file and the public API use https://www.escrova.io. Requests under `/api/` are forwarded to the backend. Use the same-origin paths below. API base: https://www.escrova.io ## Reading. No authentication, no wallet - Search every connected marketplace: GET https://www.escrova.io/api/products/search?q=...&source=... `source` is one of `amazon`, `ebay`, `walmart`, `bestbuy`, `homedepot`, `wayfair`, or `all` (the default). Those six are the marketplaces we can actually search today. Other retailers are named on the storefront as coming soon and return nothing, because no data provider we use carries them. The response carries products[], filters[], and pagination{page,limit,total,pages,totalScope,hasMore}. When pagination.totalScope is "page", total counts only the current page and hasMore says whether page+1 exists. When it is "catalogue", total is the real result count. - Live coin prices: GET https://www.escrova.io/api/prices - What we charge: GET https://www.escrova.io/api/config/fees - The current legal documents, with their version and hash: GET https://www.escrova.io/api/legal/versions ## Browsing. Narrow the category instead of widening the keyword A keyword search is one guess at what you meant. A category is the shop's own shelf, and reading one gives you the brands, the filters and a page of listings in a single call. It costs fewer calls than searching and the data compares better, so do this first and search inside it afterwards. No authentication and no wallet. Every path here is also served under /api/browse, which is the same router at a second door. - The sixteen departments, with a picture each: GET https://www.escrova.io/api/agent/browse/categories - One department, or any category under it: GET https://www.escrova.io/api/agent/browse/tree/ GET https://www.escrova.io/api/agent/browse/tree//- `children[]` and `childrenDeep[]` are the shelves under this one, biggest first, each with an estimated count. `brands[]` is the brand list with a count and an `engineParam` you can pass straight back as `ebayAspectFilter` on a search. `facets[]` are the shop's own filter groups. `aspectTiles[]`, `deals[]`, `siblings[]`, `related[]` and `total` come from the same one call. `countScope: "estimate"` means the counts are approximate, which they are. `deals[]` is a price list, so it carries an age: `contentAt` on the answer is when those prices were read. Pass it as `priceAt` on a candidate when you compare them. - The whole shop in one payload, for choosing a shelf without walking to it: GET https://www.escrova.io/api/agent/browse/directory Every department, the groups under it and the links under those. Built only from rows already cached, so it costs no catalogue read at all and a picture or a count nobody has fetched yet comes back null rather than being fetched for you. - Search inside a category, with or without words: GET https://www.escrova.io/api/products/search?cat=&q=... Two more deals reads, both public: - GET https://www.escrova.io/api/agent/browse/deals?tab=markdowns Also `refurbished`, `top-rated`, `brands`, and `door:`. Each item carries `cachedAt`, and the answer carries `updatedAt`. - GET https://www.escrova.io/api/products/deals The homepage strip. `cached: true` says it was served from our cache. Over MCP these are `list_categories`, `get_category`, `get_directory` and `get_deals`, and none of them takes a wallet. Prefer a category read, then compare a shortlist instead of issuing repeated searches. The comparison below takes shortlisted candidates rather than a query. ## Identifying yourself Wallet signatures, not cookies and not API keys. Nothing to register for. 1. POST https://www.escrova.io/api/agent/auth/challenge { walletAddress, method, path, bodySha256, delegationGrantId? } -> { message, nonce, expiresAt } 2. Sign `message` with the wallet's ed25519 key. Sign the exact bytes returned; do not rebuild the string yourself. 3. Send the request you asked for, with these headers: X-Agent-Wallet, X-Agent-Nonce, X-Agent-Signature (base58 signature over `message`) A challenge is bound to the exact method, path including query string, and body bytes. Take a fresh challenge per request. For JSON, serialize once, hash those UTF-8 bytes as a hex SHA-256 digest, then send the same bytes. For a no-body request omit `bodySha256`; `{}` is a different body. For delegated HTTP actions, include `delegationGrantId` in that challenge request. The server includes `Delegation-Grant: ` in the signed message. Send the identical selector as `X-Delegation-Grant: ` on the target request. It is outside the target JSON body but inside the signed authority, so omission or substitution after signing is refused. Check signing before money is involved: request a challenge with method `GET`, path `/api/agent/auth/whoami` and no `bodySha256`, sign its returned message, then send the three headers on: - GET https://www.escrova.io/api/agent/auth/whoami This creates the wallet's account if needed and places no order. A challenge for an order POST cannot authenticate it. The complete exchange is at https://www.escrova.io/auth.md. If pricing is temporarily unavailable, new quotes, basket affordability checks, comparisons and auction publication return HTTP 503, code PRICING_UNAVAILABLE, and retryable:true. These responses carry Cache-Control: no-store and Retry-After: 5. Wait at least five seconds, then obtain a fresh authentication challenge and retry. No new price or payment instruction is issued. MCP keeps the HTTP status and error code in its tool result; use the same retry delay. ## Accept the terms. This is the first call, not the last An agent that has not accepted the current Terms of Service and Privacy Policy is refused when it asks for a quote, before it is told to send anything anywhere. So do this first: - GET https://www.escrova.io/api/agent/legal/terms (no auth) the current version and hash you will be agreeing to - POST https://www.escrova.io/api/agent/legal/accept (agent-signed, exactly as above) The version and hash of what you agree to are read server-side from the published documents, never from your request body, and your signature over the challenge is the record. Read the documents themselves at the human pages below. Acceptance is idempotent, so calling it again is harmless, and it has to be repeated when a document version changes. If you skip it, the quote call refuses with a machine-readable pointer naming the acceptance endpoint and the version to accept. Follow that pointer rather than this paragraph: it is generated from the same code that refused you. Accepting binds you to those terms. A wallet that breaks them can be suspended, and a suspended wallet is refused at quote time. ## Can you afford it. Ask before you quote You pay out of your own wallet, so the money question comes first. Two reads, both agent-signed, both of which change nothing at all. GET https://www.escrova.io/api/agent/funds What your wallet holds right now, read off the chain: `wallet.usdc.balanceUsd` and `wallet.sol.lamports`. USDC is what an escrow is funded in. SOL is what sends the transaction that funds it, so a wallet with none cannot buy however much USDC it holds. `wallet.usdc.tokenAccountExists: false` means you have never held USDC on this cluster and your first payment also creates the account. A balance of `null` is not a balance of zero. Null means we could not read the chain; zero means the wallet is empty. Never treat one as the other. POST https://www.escrova.io/api/agent/funds/check Either a basket, exactly the body you would send to quote it: { items, shippingAddress, listingId?, gift?, creditUsd? } or a figure you already have from somewhere else, an auction bid being the case that exists today: { amountUsd } You get back `wallet`, `cost`, `credit` and `verdict`. `cost.requiredUsd` is the landed price: the item, the shipping to that destination, our fee and the tax, less whatever credit would apply. It is priced by the same code the quote runs, so it is the `totalUsd` a 402 would ask you for, to the cent, and `credit.appliedUsd` is the discount that quote would give. `verdict.affordable` is true, false, or null when the chain could not be read. `verdict.shortfallUsd` is how short you are in dollars and `verdict.solShortLamports` how short in lamports, with `verdict.reasons` naming which: USDC_SHORT, SOL_SHORT, or one of the two UNREADABLE reasons. `wallet.sol.rentFloorLamports` is the rent on the escrow's token account, read from the chain. It is a FLOOR and not the whole cost: the escrow's order account posts rent too, and the signature fees are on top. Clearing it is necessary, not sufficient. `gatesNotRun: true` is on every answer and it is the honest part. This asks whether you have the money. It does not run the compliance checks, because those call retailers and open case files, and a question must not do either. A basket you can afford can still be refused at the quote. Neither call writes anything. Do not use a throwaway quote to price-check a shortlist: a quote reserves an escrow and books any credit it promised for fifteen minutes, so ten of them would spend your own credit headroom on orders you never place. That is what these two reads are for. Over MCP these are `get_funds` and `check_affordability`. ## Comparing. The sticker is the wrong number POST https://www.escrova.io/api/agent/compare Agent-signed, writes nothing, and costs no catalogue read at all. { candidates: [ , ... ], shippingAddress: {...} or gift: { country }, creditUsd?: } Up to 12 candidates, which is the length of one category's deals list. A candidate may carry `listingId` to price a seller's own listing, and `priceAt` to say when its price was true. It must NOT carry its own `shippingAddress`, `gift` or `creditUsd`: those belong to the comparison, and one destination for the lot is what makes the numbers comparable. You get `ranked[]` cheapest to fund first, each row with `rank`, `overBestUsd`, the same `cost` and `credit` objects /api/agent/funds/check returns, the `items` that were priced, and `freshness`. Also `best`, `stickerBest`, `stickerWouldPickAnother`, and `refused[]` for candidates we will not sell, each with the code a quote would have refused with. Why it is worth a call. A real answer, priced to Austin, Texas: a seller's listing at $40 plus $15 postage lands at $55.00 a $45 item, $14.49 shipping, $9.65 credit off lands at $49.84 The $40 one looks cheaper and costs $5.16 more. Shipping is priced from the destination, tax is resolved per country, and credit comes off our fee, so it cannot apply to a seller's listing at all. `stickerWouldPickAnother: true` says the cheapest sticker is not the cheapest order. Every price is priced by the code the quote runs, so the winner's `cost.requiredUsd` is the `totalUsd` a 402 would ask for, to the cent. Send the row's `items` back when you quote and you will be asked for that number. FRESHNESS, and one trap. Each row says where its price came from: read-now priced from our own listing row, read during this request cached you sent a price and something on it says when it was true. `ageMs` is how old unknown-age you sent a price and nothing on it says when `_isLive: true` on a search row is NOT a freshness signal and is ignored here. It is stamped when a provider builds a row, and that row is then served from our cache for hours, so it says which kind of source built it and nothing about its age. A row carrying it and no timestamp comes back as `unknown-age` with `isLiveFlagIgnored: true`. Send `priceAt` if you know when you read the price. WHAT THIS ANSWER IS NOT. `pricesRechecked: false`: we did not ask the retailer what it charges now, because that is one lookup per candidate. `gatesNotRun: true`: the compliance checks did not run. `walletNotRead: true`: this says what things cost, not whether you can pay, which is /api/agent/funds/check. `quoteIsAuthority: true`: the quote is where a moved price is caught, and it refuses with PRICE_STALE before any money moves. This is advice. The quote is the promise. Over MCP this is `compare_landed_cost`. ## Your address book. Save it once, then say where by naming it Your wallet has its own account, and that account has its own saved addresses. They are yours: an address you save cannot be read by the person who set you up. Theirs cannot be read by you either, unless they have granted you authority over their account and you present that grant on the request; see "Acting for a person" below. The same routes serve a signed-in person, so nothing here is agent-only. **Save one.** POST https://www.escrova.io/api/agent/addresses with {"label": "Home", "name": "...", "address1": "...", "city": "...", "state": "...", "zip": "...", "country": "US", "phone": "+1 512 555 0100", "isDefault": true} `name`, `address1`, `city`, `zip` and `country` are required. Save a `phone` too: the address book does not insist on one, but every order does, and a saved address with no phone is refused at the quote with NO_PHONE. `deliverySpeed: "express"` is optional and asks for the faster service the quote then prices. The first address you save becomes your default whether or not you ask. The answer is 201 and the address, id included. **Read them.** GET https://www.escrova.io/api/agent/addresses answers `{ addresses }`, default first, each with its `id`, `label` and `isDefault`. **Change or remove one.** PATCH or DELETE https://www.escrova.io/api/agent/addresses/:id. Deleting the default promotes the newest survivor. An id that is not yours answers 404, the same answer an id that does not exist gets. **Then buy with it.** Send `addressId` instead of `shippingAddress` on a quote or an affordability check, or send neither and we use your default. The price is identical either way: the saved address is resolved to the same fields you would have typed, before anything is priced. Which address a bare order uses: the one marked default, and if none is marked, the newest you saved. An account with no saved address at all is refused with NO_SHIPPING_ADDRESS rather than shipped anywhere by guess. Two refusals worth knowing. `addressId` and `shippingAddress` in one body is ADDRESS_AMBIGUOUS: they name two destinations and we will not pick. An `addressId` on a claim-link gift is ADDRESS_NOT_FOR_GIFT, because the recipient types their own address on the claim page. ## Acting for a person. Only if they said so, and only until they say stop Everything above is you acting as YOURSELF: your account, your orders, your credit, your address book. There is a second mode, and it exists only because somebody chose it. A person signed in to Escrova can grant your wallet authority over THEIR account. While that grant is live you can list for them, edit and withdraw their listings, read and write their saved addresses, open and evidence their disputes, and set their price watches. You are still not them: every single thing you do under a grant is recorded against their account naming your wallet, and they can read all of it and stop all of it in one action. **You cannot make a grant.** POST /api/delegation/grants takes a signed-in person and refuses a wallet signature. You also cannot widen one, and you cannot revoke one. Ask the person; they do it from their own account. **See what you were given.** GET https://www.escrova.io/api/agent/delegation/grants answers `{ grants }`, each with its `id`, `label`, `scopes`, `status`, `expiresAt` and `limits`. Only grants made to your own wallet are listed. `status` is ACTIVE, EXPIRED or REVOKED, and only ACTIVE authorises anything. **Use one.** Send the header X-Delegation-Grant: on the request, beside your usual wallet, nonce and signature. That is the whole mechanism. Without the header nothing changes and you act as yourself, so holding a grant does not silently move your own shopping onto somebody else's account. With it, the routes you already know read and write their rows instead of yours. Over MCP, put `delegationGrantId` in `targetArguments` for `auth_challenge`, then pass the same value to the target tool. The adapter binds that selector into the signed challenge message and forwards it as `X-Delegation-Grant`; it is not inserted into the target JSON body. The current delegated MCP subset covers creating, listing and deleting watches; listing and saving addresses; reading and adding dispute evidence; and creating a listing. `get_delegation` lists the grants available to your wallet. Grant-enabled HTTP actions without a matching MCP tool still use the same signed HTTP headers described above. The scopes are `buy`, `sell`, `disputes`, `addresses` and `account`. A grant made without naming any holds all five. A request under a scope the grant does not hold is 403 SCOPE_NOT_GRANTED and is final: retrying will not help. **Read what you did.** GET https://www.escrova.io/api/agent/delegation/actions answers `{ actions }`, your own record, refusals included. The person sees the same rows on their side. **Every refusal you can meet, and whether to retry.** GRANT_NOT_FOUND no grant with that id was made to your wallet GRANT_REVOKED taken back. Final. Ask for a new one GRANT_EXPIRED lapsed. Final. Ask for a new one SCOPE_NOT_GRANTED the grant does not cover that. Final OVER_PER_ACTION_LIMIT one action, over the ceiling. Final OVER_DAILY_LIMIT this grant's rolling 24 hours are used up OVER_TOTAL_LIMIT this grant's lifetime ceiling is used up PRINCIPAL_BANNED that account is suspended. Final GRANT_NEEDS_AGENT_CREDENTIAL the header only works with a wallet signature DELEGATED_SIGNING_OFF see below **A GRANT CANNOT SPEND THEIR MONEY TODAY, AND WE WILL NOT PRETEND OTHERWISE.** Buying on somebody else's account means signing with their wallet, and that is switched off on this deployment. A quote under a grant is refused at once with 503 DELEGATED_SIGNING_OFF, before anything is priced and long before anything is funded, so no escrow is ever left holding money for an order this rail cannot finish. Drop the header and buy the same basket with your own wallet, which works exactly as it always has. Do not retry the delegated form: nothing about waiting arms a deployment. **What `disputes` covers, exactly.** Reading their dispute, filing evidence on it, and declining a settlement offer. Those are the three that need no signature and take nothing away. It does NOT cover OPENING one for them. Opening a dispute on an escrow-program order needs `open_dispute` called on chain by the key that funded it, which is their wallet, and that is the switched-off half above. Ask them to open it; you can evidence it from that moment on. It does NOT cover ACCEPTING a settlement, and never will. That concedes escrowed money, it cannot be undone at any layer, and a standing grant made three weeks ago is not somebody agreeing to today's offer. The refusal is 403 HUMAN_CONFIRMATION_REQUIRED whether or not you hold a grant. ## Buying. One endpoint, two calls, with your own money in between POST https://www.escrova.io/api/agent/orders **Call 1, the quote.** Send { items, shippingAddress } and, for a peer-to-peer listing, { listingId }. Each item is an object from our own search results: { name, priceUsd, quantity, source, externalUrl } Copy those fields from GET /api/products/search rather than composing them: the quote is fingerprinted over exactly what you send, and the server prices the order itself regardless of the priceUsd you supply. A DESTINATION IS MANDATORY and there is no quote without one. Name it in whichever of the three ways suits you: a full `shippingAddress`, an `addressId` from your own address book, or nothing at all, which uses your default. MCP `create_quote` supports all three, and `list_addresses`/`save_address` manage your own address book. The resolved address must carry `phone` with 7 to 15 digits, country code included: carriers and retailers refuse an order nobody can be called about, so a phoneless address is refused at the quote with NO_PHONE, before any money moves. The shipping charge comes from the destination's zone and the tax position is resolved per country, so a price with no destination would be a number we could not stand behind. Having none at all is refused with NO_SHIPPING_ADDRESS. When the destination came out of your address book the 402 carries its `addressId` back. Send that on call 2 and the address is pinned: the quote is fingerprinted over where it is going, so an address book you change in between would otherwise re-resolve to somewhere else and be refused with your escrow already funded. The ONE exception is a claim-link gift, where the address belongs to somebody who has not told you it yet. That shape is not available on this rail yet and is refused with `GIFT_NOT_AVAILABLE`; the gifting section says what to do instead. Optional on `shippingAddress`: `isGift: true` and `giftMessage` (up to 240 characters). The retailer prints the message on a card and hides the price on the packing slip. If a store cannot honour the gift option for an item, the order is not quietly sent as a plain parcel: placement is refused, an operator decides between placing it without the gift or refunding, and the buyer is told either way. That is one of two gift shapes, and it is the one to reach for when you know where the present is going. The gifting section below has the other one, for when you do not. Optional: `creditUsd`, if you hold Escrova Credit and want to spend it on this order. It is a ceiling on a request, never an amount: the server decides how much it will apply and tells you in the quote. See the credit section below. The answer is **402 Payment Required**. That is this call succeeding, not an error. It carries: challengeId, expiresAt payment { scheme: "solana-escrow-v2", network, asset (the USDC mint), amount, currency, payTo { escrowProgram, orderId, orderAccount, escrowTokenAccount, seller, buyer, orderType } } quote { orderType, subtotalUsd, shippingUsd, taxUsd, grossTotalUsd, creditRequestedUsd, creditAppliedUsd, creditRefusedBecause, totalUsd } retry { method, path, body } **Fund `totalUsd`, which is the same number as `payment.amount`.** It is already net of any credit: `grossTotalUsd` is what the order costs, `creditAppliedUsd` is what came off, and `creditRefusedBecause` says why nothing did when nothing did. Never fund `grossTotalUsd` when a discount was applied, and never assume a discount that the quote does not state. **Then fund the escrow yourself.** Call `create_order` on the escrow program named in payTo, with that exact order_id, amount, seller and order_type, then `fund_order` from your own token account. The program's Anchor IDL is published at https://www.escrova.io/escrow-v2.idl.json, so you can build both instructions without ever having seen this codebase. The escrow is non-custodial: Escrova cannot move those funds except through the dispute and refund paths written in the published terms. **Call 2, the proof.** Send the same request again, plus `challengeId` and `orderPDA`. Send the same items, listingId and destination: the quote is fingerprinted over all of them, so a changed cart is refused rather than charged at the old price. An address you named by `addressId` may be sent either way on this call, by id or in full; it is the resolved address that is fingerprinted, never the id. Any `creditUsd` you send again is ignored: the discount was fixed when the quote was issued and is already in the amount you funded. We read the chain. Nothing you claim about the payment is trusted. On success the answer is 201 with the order and the receipt. Settlement is idempotent: repeating call 2 returns the same receipt and books nothing twice. The order carries `creditAppliedUsd`, which is what was actually taken off your balance, and `amountUsdc`, which is what you funded. The receipt restates the discount too. Check `creditAppliedUsd` against the `creditAppliedUsd` in the quote rather than assuming they matched. **Tracking.** GET https://www.escrova.io/api/agent/orders/:id takes either identifier you hold, the order row id or the on-chain order id. It returns the status, the carrier events with a `verified` flag on each, `creditAppliedUsd`, and `releaseAt`, the recorded release schedule. The applicable dispute window can keep funds held beyond that schedule. Raise a dispute promptly when there is a problem; `releaseAt` alone is not the final dispute deadline. ## Disputes. Open one and evidence it yourself; a person accepts You can open a dispute on your own order and file everything you know about it, with no confirmation from anybody. You cannot accept a settlement. That one stays with a human, and this section says exactly where the line is so you do not plan around a call that will refuse you. Why the line is there: a dispute decides whether money already in escrow comes back to you or goes on to the seller. Opening one and filing evidence take nothing away, and an operator weighs what you file. Accepting gives money up, the escrow is emptied by the resolution that follows, and no layer here has an undo for it. **Open one.** POST https://www.escrova.io/api/orders/:id/dispute with `{"reason": "..."}`. This is the one dispute call on the ordinary orders path rather than under /api/agent, because it is the same handler a person uses and there is no second copy of it. Sign for that exact path. FREEZE THE ESCROW FIRST. For an order held by the escrow program, call `open_dispute` on chain with the key that funded it, then make this call. The route checks the chain and answers 409 `CHAIN_NOT_DISPUTED` if the escrow is not frozen yet, because the message it sends you says your funds are held and that sentence has to be true when it is sent. **File evidence.** POST https://www.escrova.io/api/agent/disputes/:orderId/evidence {"body": "the tracking page has said the same thing for 14 days", "photos": ["/uploads/....jpg"]} `body` is required, up to 2000 characters, and ten filings per dispute is the ceiling. `photos` is optional and takes only images already uploaded to Escrova through POST /api/upload; a link to another site is refused, because an operator is asked to open these. **Read it.** GET https://www.escrova.io/api/agent/disputes/:orderId answers the status, everything filed, `filingsLeft`, every settlement offered, and `liveSettlement`, which is the one that can still be answered. Do not work that out from dates yourself: `liveSettlement` is null once the deadline has passed, whatever `status` still says on the row. **Settlement offers.** An operator may put a way to settle to you: all of the money back, all of it to the seller, or a split with a percentage. It arrives as `DISPUTE_SETTLEMENT_OFFERED` on GET /api/agent/notifications, with the deadline in the body and in `metadata.expiresAt`. You may DECLINE it: POST https://www.escrova.io/api/agent/disputes/:orderId/settlement/:settlementId/decline Declining reaches the same place as saying nothing, minus the wait. No money moves, the offer closes, and an operator decides the dispute on the evidence. You may NOT ACCEPT it. The accept route answers 403 `HUMAN_CONFIRMATION_REQUIRED` to a wallet signature, every time, and the refusal names what you can still do. A wallet signature proves a key signed a request; it is not a person agreeing to give up money. If the account is an agent with nobody linked to it, no accept is possible at all, and we say so rather than routing the decision to somebody who never agreed to make it. The operator handling the dispute is told the same moment the offer is made, so they resolve it themselves instead of waiting for an answer that cannot come. Tell whoever runs you; they can accept from their own account if the order is theirs. **If nobody answers, nothing is settled.** An offer that reaches its deadline lapses. It does not become an acceptance, it never has, and there is no path in this system that reads silence as agreement. You get `DISPUTE_SETTLEMENT_EXPIRED`, no money moves, and the dispute stays open for an operator to decide. So a missed message costs you a choice and never the order. ## Gifting. Agents can order to a known address; claim links need a person **Pick by whether you have the address.** That is the whole decision. **You have it.** Send an ordinary order and put `isGift: true` and `giftMessage` on `shippingAddress`, exactly as the buying section describes. One order, on your own wallet, priced to the address you sent, and nothing waits on anybody. Reach for this one whenever you can: it is one call, it cannot expire, and nobody has to open an email for it to arrive. **You do not have it.** Then the shape you want is a claim link: the buyer prepays for the item and names only the destination country, we email a link to somebody who may not have an account, and they open it, sign in or sign up, prove they can read that mailbox, type their own address, and the order proceeds from there. THAT DOORWAY IS NOT OPEN TO YOU, for two separate reasons. POST https://www.escrova.io/api/gifts takes a signed-in person's session and there is no wallet-signed twin of it. And sending `gift` on POST https://www.escrova.io/api/agent/orders is refused with 501 `GIFT_NOT_AVAILABLE` at the quote, before anything is priced and before any escrow exists, so a refusal costs you one call and nothing else. The half that mints the claim credential and sends the mail is not built on the agent side. The refusal is `retryable: false`. Do not poll it and do not vary the body: no argument makes it succeed today. Use the address shape above and ask the person where to send it, or hand the job to a person. This is a gap and not a rule, and this document changes when it closes. **You can be on the receiving end.** Only the GET is public, so an agent can see what was sent without holding anything. CLAIMING NEEDS A SESSION and needs a code we email to the address the sender named, so an agent holding a link alone cannot take the parcel. That is the point of it: the token in the path is not a credential on its own any more. - GET https://www.escrova.io/api/gifts/claim/:token What the link opens: `claimable`, `items[]` (each `name`, `image`, `quantity`), `from` (a first name, or null), `message`, `expiresAt` and `country`. There is no order id, no wallet, no account and no email address anywhere in that answer. That is the design, not an omission. - POST https://www.escrova.io/api/gifts/claim/:token/code Signed in. Emails a six digit code to the address the SENDER named, and answers `{sent: true, expiresInMinutes}`. It never says where it went, not even masked: the person who should read it knows which inbox they opened. - POST https://www.escrova.io/api/gifts/claim/:token/verify Signed in. `{code}`, and `{proven: true}` back. Every wrong state, including a code minted for a different account, answers one identical sentence. - POST https://www.escrova.io/api/gifts/claim/:token Signed in, and only after the two above. GIFT_CODE_REQUIRED means the mailbox has not been proved; GIFT_CLAIMER_REFUSED means this account cannot claim gifts at all and no edit to the body changes it; ACCOUNT_NOT_READY means the session is real but the account row is still being written, and is worth retrying in a second. The address, either as `{ address: {...} }` or at the top level. It needs `name`, `address1` (or `street`), `city`, `zip` (or `postalCode`) and `phone`, because a retailer refuses an order it cannot post or call about. The country has to be the one named in the GET, since the shipping flat and the tax position were both resolved from it when the gift was paid for; anywhere else is refused with GIFT_WRONG_COUNTRY rather than shipped at a price nobody paid. 200 with `{claimed: true}` is done. 202 with GIFT_HELD_FOR_REVIEW means a person is checking one thing and the recipient has nothing left to do or fix. **One 404 for every dead token, byte for byte.** Unknown, expired, already claimed, already refunded, or attached to an order that has moved on all answer the same way. You cannot tell them apart, and you are not meant to: that is what stops this endpoint being used to find out whether a token exists. Do not walk tokens. Every answer here carries `X-Robots-Tag: noindex`, so do not put a claim link anywhere a crawler can reach it, because a link that leaks is a present somebody else opens. **What the buyer of a gift is told, in case you are asked.** State, and never an identifier: `status`, `expiresAt`, and `linkSent` for whether the email actually went out. Not the recipient's address, not their email, not the raw token. The two sides of a gift are deliberately kept from learning each other, and nothing you can call changes that. ## Auctions. Bid with your own signature; only the last step touches us Sellers can list an item as an auction. Your bid is a deposit into a vault only your key can move money out of; the highest bidder is locked in, everyone else can take theirs back at any moment, and when you win the same money becomes the escrow for your order. Reading needs nothing. Bidding is an instruction you sign against the auction program directly: this server never sees a bid. Only the winner's last step, recording the order with a shipping address, is a call here. **You can bid, and you can now run one of your own.** An auction runs off a marketplace listing, and both take an account we have verified and approved. That account can be yours: see "Selling" below for how a wallet verifies as itself, then opens an auction with POST /api/agent/auctions/prepare and POST /api/agent/auctions/confirm. Until your account is approved those two refuse you with 403 KYC_REQUIRED, which is the same refusal an unverified person gets. **Accept the terms before you bid.** The buying flow refuses an unaccepted wallet at the quote, before any money moves. An auction has no quote, so the refusal comes at the record step instead: it is retryable and carries the acceptance instructions, and your escrow is untouched while you accept, but accepting first is simpler. **Read.** No authentication: - Open auctions, ending soonest first: GET https://www.escrova.io/api/auctions?status=live (`status=all` for every auction). Each carries `auctionId` (64 hex), `auctionPDA`, `paymentMint`, `startPriceUsd`, `highestBidUsd`, `highestBidder`, `minIncrementRaw` (in the mint's base units), `endAt` and `hardEndAt`. - One auction as the chain reports it: GET https://www.escrova.io/api/auctions/:auctionId Includes `paymentDeadline` once it has ended, and `chain.readAt` so you can anchor a clock. - The book: GET https://www.escrova.io/api/auctions/:auctionId/bids - The history: GET https://www.escrova.io/api/auctions/:auctionId/history - Your own position: GET https://www.escrova.io/api/auctions/:auctionId/bid/:yourWallet Two clocks. `endAt` is when bidding closes and a late bid EXTENDS it (by `extDeltaSecs` when a bid lands inside the last `extWindowSecs`); `hardEndAt` is the ceiling nothing can move. Poll rather than trust a local countdown. **The program.** Address `ABvJnCXp8Ti76tHBRNqzHtQemXkMLtqQeLpZ1Qvs1CBQ` on the same cluster as the escrow. Its Anchor IDL is published at https://www.escrova.io/escrova-auction.idl.json. The IDL does not carry PDA seeds, so here they are (all seeds are the raw bytes, program id is the one above): - auction = ["auction", auction_id (32 bytes)] - bid = ["bid", auction, bidder] - bid_vault = ["bid_vault", auction, bidder] (a token account for paymentMint) **Bid.** Two instructions: - `open_bid`, once per auction per wallet, creates your Bid account and vault. Accounts: rent_payer (signer, you), bidder (signer, you), auction, bid, bid_vault, payment_mint, token_program, system_program. - `place_or_raise_bid(deposit_amount, min_expected_total)` moves `deposit_amount` from your token account into your vault. Your bid IS your vault total, so a raise sends only the difference. `min_expected_total` is your floor on the resulting total; the program reverses the whole instruction if less arrives (a transfer-fee mint). Accounts: bidder (signer), auction, bid, bid_vault, bidder_token_account (your ATA for paymentMint), payment_mint, token_program. Send both in ONE transaction for a first bid: an open that lands alone leaves an empty account holding your rent. A bid must clear the current high by at least `minIncrementRaw`; the program refuses otherwise (BidTooLow, 6011), and somebody else's bid landing while yours is in flight is the ordinary way that happens, so re-read and try again. The seller cannot bid on their own auction. Bidding needs a little SOL for rent and fees; there is no relay for this program. **Withdraw.** `withdraw_bid` returns your whole vault to you, any time, under your own signature, unless you are the current highest bidder while the auction is Live or awaiting payment. Losing deposits are ALSO refunded for you by our crank after the auction ends, so you do not have to know you lost. **How you learn you won.** Two ways, and use both: - POLL. `GET /api/auctions/:auctionId` after `endAt`. You have won when `highestBidder` is your wallet and either `status` is `awaitingPayment` or `endAt` has passed while it still reads `live` (nothing settles an ended auction for you; see below). The same answer carries `paymentDeadline`. - READ YOUR MESSAGES. GET https://www.escrova.io/api/agent/notifications (agent-signed) is every message this platform has written for your wallet, newest first: `AUCTION_OUTBID` (your deposit just unlocked), `AUCTION_WON` with the deadline in it, `AUCTION_PAY_REMINDER` a day before it expires, `AUCTION_BID_REFUNDED`, `AUCTION_VOID`, and the order messages after that. Send back the `cursor` from the previous answer as `?since=` and you get only what is new. `PATCH /api/agent/notifications/read` `{ids:[...]}` or `{all:true}` if you would rather the server held your place. This exists because the bell and the emails are built for a person: without it we would be writing you messages you had no way to read. **When you win.** You have `paymentDeadline` (three days after `endAt`) to pay. Payment is one transaction: `settle_auction` first if the auction is still Live past its end (it takes one account, `auction`, no signer, and moves it to awaiting payment); then the escrow program's `create_order` (IDL at /escrow-v2.idl.json; buyer = you, seller = the auction's `seller`, mint = the auction's `paymentMint`, amount = your vault balance, order_id = 32 random bytes you generate); then the auction program's `pay_from_bid`, which moves your vault into that escrow. Accounts for `pay_from_bid`: winner (signer), auction, bid, bid_vault, winner_token_account, order (the escrow Order PDA your order_id derives to), escrow_token_account, platform_config (the ESCROW program's config PDA), rent_payer (read it off your Bid account), payment_mint, token_program, escrow_program. `create_order`'s arguments are (order_id, amount, seller, order_type = P2P, metadata_uri). Buying does not need the escrow program's PDA seeds, because the 402 answer hands you every address. Winning an auction does: you mint the order_id yourself, so you derive them. Under the escrow program id in that IDL: - order = ["order", order_id (32 bytes)] - escrow_token_account = ["escrow", order_id] (a PDA in its own right, NOT an associated token account of anything) - escrow_authority = ["escrow_authority"] (no order id in this one) - platform_config = ["platform_config"] `create_order` takes ten accounts: rent_payer (signer), buyer (signer), order, platform_config, token_mint, escrow_token_account, escrow_authority, system_program, token_program, rent. Both IDLs are hand-generated and carry no address hints, so an Anchor client will not fill the last three in for you. Then record it, so the seller learns where to ship: POST https://www.escrova.io/api/agent/auctions/:auctionId/order body { orderId, orderPDA, shippingAddress, payTxSig? } signed exactly like every agent call. `orderId` is the hex you generated, `orderPDA` the escrow account it derives to; both are checked against what the chain says the auction settled to, and only the wallet the chain names as highest bidder may record it. `shippingAddress` takes the same shape as a purchase (street/postalCode or address1/zip, phone included). The answer is the order; track it at GET /api/agent/orders/:id like any purchase, and the same escrow release and dispute rules apply. Miss the deadline and, as things stand, the auction voids after a grace period and every deposit, yours included, comes back; the program also carries a forfeit rule for unpaid wins that is not armed today, and this document changes before it is. The MCP server exposes `list_auctions`, `get_auction` and `record_auction_win` for the same loop. Ask `check_affordability` with a bare `amountUsd` before you bid: a deposit is real money and the bid is your own signature, not ours. ## Selling. Verify as yourself, then list as yourself You can sell here. By default the account that sells is YOUR account, the one your wallet signature resolves to: your listings, your auctions, and your payouts to the wallet you already sign with. Every seller endpoint writes the seller from the credential that signed the request, and nothing in a body is read for it: naming somebody else's account id in a body changes nothing at all. The one way to sell for a person is the one they chose: a grant holding the `sell` scope, presented in the X-Delegation-Grant header. See "Acting for a person" above. The listing is then theirs, it is approved against THEIR verification rather than yours, and every act is on their record naming your wallet. Without that header you cannot list for anybody and cannot touch anybody else's listing. **One gate, and it is the same one a person meets.** Until your account is `APPROVED` every seller endpoint answers 403 with `code` `KYC_REQUIRED`. The sentence and the code are the same ones an unverified person is given, because it is the same line of code. **Verify.** POST https://www.escrova.io/api/agent/kyc/start with an empty body `{}`. The answer is `{ mode, kycStatus }`. Read `mode`: - `manual`, which is what a wallet always gets: your request is queued and a person at Escrova decides. `kycStatus` becomes `PENDING`. Nothing else is asked of you and there is nothing to upload; the result arrives as a notification you can read at GET /api/agent/notifications. - `done`, if you were already approved. If the answer also carries `providerUnavailableReason: 'PROVIDER_NEEDS_A_PERSON'` it means our identity provider is switched on and its check is a photograph of a government document and a face. There is nothing a wallet can submit to it, so we do not hand you a token you could never spend. Your route is the queue above. That is a real boundary and not a delay we are hiding: your account is approved by a person at Escrova or not at all. **Check where you stand.** GET https://www.escrova.io/api/agent/kyc/status answers `{ kycStatus, providerConfigured, mode }`. `kycStatus` is one of NONE, PENDING, APPROVED or REJECTED. `providerConfigured` is a fact about our server; `mode` is the fact about your account, and for a wallet it is always `manual`. **List an item.** POST https://www.escrova.io/api/agent/listings with `{ name, description, images, category, condition, priceUsdc, quantity, shipsFrom, shippingUsd, feeTxHash }`. Publishing costs 0.005 SOL, paid on chain to our treasury BEFORE this call, and `feeTxHash` is that signature; it is checked against the chain and can be used once. `images` are addresses we can serve, so upload them first or paste from a host we already proxy; a URL we cannot render is refused here rather than showing a buyer a blank tile. The answer is your listing at `status: 'PAUSED'`, `reviewStatus: 'PENDING_REVIEW'`. Every listing goes to a human reviewer, yours included, and goes live when it is approved. Over MCP, `create_listing` accepts the same `name`, `priceUsdc`, `feeTxHash` and shipping fields. Earlier `title` and `priceUsd` arguments remain aliases; conflicting alias values are refused. Use `auth_challenge` with `targetTool: "create_listing"` and the same `targetArguments` to bind the fee signature and the canonical REST fields to the request you sign. If you paid and then lost the answer, POST https://www.escrova.io/api/agent/listings/claim-fee with `{ feeTxHash }` rather than paying again. **Your own listings.** GET https://www.escrova.io/api/agent/listings/mine answers only yours, at every status. PATCH https://www.escrova.io/api/agent/listings/:id edits one and DELETE https://www.escrova.io/api/agent/listings/:id withdraws it. Somebody else's id answers 403 and changes nothing. A listing under a live auction is frozen: both answer 409 `AUCTION_IN_PROGRESS`, because bidders committed money against exactly what it says now. **Sell it by auction instead.** Two calls, with your own signature in between, and they are the wallet-signature twins of the seller doorways a person uses: POST https://www.escrova.io/api/agent/auctions/prepare body { listingId, startPriceUsd, minIncrementUsd, durationSecs, hardEndSecs?, extWindowSecs?, extDeltaSecs?, payCoin? } writes nothing and hands you every argument for `create_auction`: `auctionId`, `auctionPda`, `programId`, `listingHash`, `paymentMint`, the raw amounts, the schedule, and `sellerWallet`, which is your own wallet, because an auction pays out to the wallet that signs it. An auction runs at least 12 hours and at most 7 days. Sign `create_auction` yourself, then: POST https://www.escrova.io/api/agent/auctions/confirm body { auctionId, listingId, createTxSig? } which reads the auction back off the chain and mirrors it. The body is a pointer only: every value we record is the chain's, not yours, so signing different parameters from the ones you asked for gets you the auction you actually signed or a refusal, never a mirror that lies. Refusals carry `retryable`: a chain we could not read is worth sending again, a gate or a bound is not. **Selling obligations, in one paragraph.** The 5% platform fee comes out of your proceeds at on-chain release, exactly as it does for a person. You sign `mark_shipped` yourself, and the buyer's escrow releases to you on the schedule in the terms. A buyer can open a dispute, and a dispute decided against you returns their money. Accept the terms before you list. ## Credit and the referral programme. Yours, and spendable Escrova runs a referral programme and issues credit that reduces what a buyer pays. Your wallet is a real account, so it earns credit on its own completed orders and it can spend that credit on its own next order. These take a wallet signature exactly like everything else here. **What credit is.** It comes off Escrova's own fee and the shipping and never off the item's price, so it can only ever shrink our half of the bill. It is capped per order as a share of those two lines. It works on concierge orders and never on a peer-to-peer listing, because there the shipping and the fee are the seller's and discounting them would be us spending a stranger's money. It is not money and it does not leave the platform: it cannot be transferred, cashed out or bought. And the whole programme has one master switch: while it is off, nothing is earned, nothing is applied, and an order prices to the cent exactly as it would if none of this existed. **Read what you hold.** - GET https://www.escrova.io/api/credit/me answers `{ balanceUsd, byKind, nextExpiryAt }` for the account behind your wallet, and for no other account. Credit expires; `nextExpiryAt` is when the soonest of it does. - GET https://www.escrova.io/api/referrals/me answers your own code, the accounts you introduced, and which programme is actually paying today. - GET https://www.escrova.io/api/config/fees carries every published rate, including the share of an order that credit is allowed to cover. Read it there rather than carrying a copy. - GET https://www.escrova.io/api/referrals/who/:code answers `{ name }`, a display name or a shortened wallet, so an invitation can say who sent it. A code that belongs to nobody is a 404. No signature needed for this one. **Spend it.** Send `creditUsd` on the quote call, as described in the buying section. The number you send is a ceiling and never an instruction: the server applies the smaller of what you asked for and what it will allow, and the 402 tells you the figure. Fund `totalUsd`. The order then reports `creditAppliedUsd`, and the two should be the same. Credit already promised to a quote you have not paid for is not offered twice. If you hold several live quotes at once, the second one is told `creditRefusedBecause: "COMMITTED_ELSEWHERE"` rather than a number that could not be honoured. Pay or abandon the first, and quote again. **Be invited.** POST https://www.escrova.io/api/referrals/redeem with `{ code }` applies somebody's code to your account. One code per account for life, and never your own. Redeeming the code of somebody you introduced is not refused at this call, and it earns nothing: the walk up the tree stops at the first account it has already seen. Nobody has to buy anything for a code to be applied. **Two doors are closed to a wallet, and here is why.** - POST /api/referrals/code, which reserves a custom code, is refused with `AGENT_NOT_ELIGIBLE`. A custom code is permanent and unique across all of Escrova, and a wallet costs nothing to generate, so wallet-signed accounts cannot reserve one. The code on GET /api/referrals/me is yours and invites exactly the same. - POST /api/referrals/claim, which pays referral rewards out in USDC, is refused with the same code. That sends real money to an address, which is a different question from a discount on your own bill. Your earnings are spent as credit on your own orders instead. Both refusals carry `retryable: false`. They are decisions, not failures, so do not retry them. Over MCP these are `get_credit`, `get_referrals`, `lookup_referral_code` and `redeem_referral_code`, and `create_quote` takes `creditUsd` like the HTTP call does. ## The same rail, as MCP tools Everything above is also a Model Context Protocol server: https://www.escrova.io/api/mcp Streamable HTTP, tools only, and stateless. Send each request as a POST; GET and DELETE are the session half of that transport and are refused with a 405 that says so, because there is no session to stream or delete. The server card is at https://www.escrova.io/.well-known/mcp/server-card.json. Credentials travel as TOOL ARGUMENTS rather than as headers, because the MCP transport has no place to carry them: `walletAddress`, `nonce` and `signature` are fields on every tool that needs a wallet. The signing is the same signing described above, the wallet stays where it already is, and Escrova never holds a key. EVERY TOOL THAT TAKES A WALLET NAMES THE METHOD AND PATH to bind its challenge to, in its own description. Read that before calling it. A signature authenticates one method, one path and one body, so a tool that did not say which left the caller guessing in two dimensions on the step whose whole purpose is to prove signing works before money is involved. The tools, and the endpoint each one calls: search_products GET /api/products/search get_fees GET /api/config/fees get_prices GET /api/prices get_terms GET /api/agent/legal/terms list_categories GET /api/agent/browse/categories get_category GET /api/agent/browse/tree/[/] get_directory GET /api/agent/browse/directory get_deals GET /api/agent/browse/deals?tab= auth_challenge POST /api/agent/auth/challenge whoami GET /api/agent/auth/whoami compare_landed_cost POST /api/agent/compare get_funds GET /api/agent/funds check_affordability POST /api/agent/funds/check accept_terms POST /api/agent/legal/accept create_quote POST /api/agent/orders confirm_order POST /api/agent/orders get_credit GET /api/credit/me get_referrals GET /api/referrals/me lookup_referral_code GET /api/referrals/who/ redeem_referral_code POST /api/referrals/redeem list_auctions GET /api/auctions get_auction GET /api/auctions/ record_auction_win POST /api/agent/auctions//order list_notifications GET /api/agent/notifications get_order GET /api/agent/orders/ create_watch POST /api/agent/watches list_watches GET /api/agent/watches delete_watch DELETE /api/agent/watches/ list_addresses GET /api/agent/addresses save_address POST /api/agent/addresses open_dispute POST /api/orders//dispute get_dispute GET /api/agent/disputes/ file_dispute_evidence POST /api/agent/disputes//evidence decline_settlement POST /api/agent/disputes//settlement//decline get_verification_status GET /api/agent/kyc/status start_verification POST /api/agent/kyc/start create_listing POST /api/agent/listings get_delegation GET /api/agent/delegation/grants list_delegated_actions GET /api/agent/delegation/actions For an authenticated MCP call, first call `auth_challenge` with `walletAddress`, `targetTool` and the target tool's `targetArguments`. The adapter uses the same request contract for preparation and dispatch: it encodes path parameters, orders query parameters, selects only the tool's JSON body fields, canonicalizes nested object keys and hashes those exact UTF-8 bytes. The answer includes `request.method`, `request.path`, `request.body` and `request.bodySha256` beside the message. Sign the returned message, then call that same target tool with the same arguments plus `walletAddress`, `nonce` and `signature`. Use one fresh challenge per call. This preparation covers path-parameter writes such as `record_auction_win`, `open_dispute` and `file_dispute_evidence`, and query paths such as `list_notifications`; do not rebuild or reorder their path or body between the two calls. Direct HTTP clients may still use the legacy manual method, path and body hash form described earlier. What is not a tool. The table above is the whole set, so anything in this file that is not in it is an HTTP call. HTTP only: the auction book reads (`/bids`, `/history`, `/bid/:yourWallet`), marking notifications read (PATCH /api/agent/notifications/read), the homepage deals strip, and the public legal versions list. Three things are not tools because no credential you hold can do them, and a tool that always fails reads as a capability. - **Accepting a settlement.** It decides where escrowed money goes and there is no undo, so it needs a person. `decline_settlement` is a tool because declining concedes nothing. See the disputes section. - **Creating or revoking a delegation grant.** Both need the person's own login. `get_delegation` shows you what you have been given and `list_delegated_actions` shows what was done with it. - **A claim-link gift.** `create_quote` and `confirm_order` take no `gift`, and the HTTP route refuses one with `GIFT_NOT_AVAILABLE`. The other gift shape needs no tool of its own: put `isGift` and `giftMessage` on the shipping address of an ordinary order. ## Watching a price. Ask once, be told when it drops Rather than polling a product every hour, leave the question with us. A watch is a standing "tell me when this drops below X". When it is met you get a notification, which you read on the same cursor as everything else. These take a wallet signature. They are also open to a signed-in person through the identical routes, so nothing here is agent-only. **Set one.** POST https://www.escrova.io/api/agent/watches with either {"productId": "", "thresholdUsd": 40} or, to watch the cheapest thing a search finds, {"query": "burr grinder", "thresholdUsd": 40} `label` names it back to you in the message. `days` is how long it runs, 1 to 90, and 30 if you say nothing. There is no forever. The answer is `{ created, watch }` and 201 when the row is new. Search watches use eBay by default. Sources unavailable for search watches return `SOURCE_NOT_WATCHABLE`. To watch an item from another supported store, use its `productId` instead. Sending the same target again UPDATES the watch you already have and answers `created: false`. Retry a create you lost the answer to; do not delete first. **Read them.** GET https://www.escrova.io/api/agent/watches answers `{ watches, live, limit }`. `limit` is how many live ones this account may hold, `live` is how many it has, and each row carries `checks`, `nextCheckAt` and `lastValueUsd` so you can see what it has cost and when it is next looked at. `?status=ACTIVE` narrows it. **Stop one.** DELETE https://www.escrova.io/api/agent/watches/:id. It stops immediately and frees a slot. **What to expect, so you do not build around the wrong model.** - A watch is evaluated every six hours, not continuously. Each evaluation is a retailer call out of an allowance real shoppers are spending, which is also why there is a cap per account and why 409 `WATCH_LIMIT` is a decision rather than a failure. - It fires ONCE. A price that stays low sends one message, not one an hour, and the watch then stops. Ask again if you want to hear again. - The message is `WATCH_PRICE_DROP` on GET /api/agent/notifications, with the watch id, the price and your threshold in `metadata`. - Strictly below. A price exactly equal to your threshold is not a drop. ## Ground rules - Payments settle on Solana and the total is verified on-chain by the server. Client-claimed amounts are never trusted or spent. - Every purchase is covered by the same returns and cancellation policy a human shopper gets. - Rate limits apply per wallet and across all agent traffic. A wallet costs nothing to generate, so both ceilings exist. Abusive traffic is dropped. - A held order is not a failed one. The money is still in escrow and a person is looking at it. Do not retry it as if it had failed. ## The same pages, as markdown Every prerendered page has a markdown twin at the same path with `.md` on the end. It carries the page's own canonical URL and description, and none of the icons, buttons or layout wrappers, so there is nothing to strip before reading it. Each HTML page also points at its twin with ``. https://www.escrova.io/index.md the storefront https://www.escrova.io/market.md the peer-to-peer marketplace https://www.escrova.io/deals.md https://www.escrova.io/terms.md https://www.escrova.io/privacy.md https://www.escrova.io/returns.md https://www.escrova.io/disclaimer.md https://www.escrova.io/subprocessors.md The build prerenders 24 routes: these eight plus sixteen category doors under `/c/`. Each category door also has a markdown twin, for example https://www.escrova.io/c/electronics.md. Product, listing and auction pages are rendered in the browser and have no markdown twin; use the API for those. `Accept: text/markdown` on the HTML URL does NOT work today and is honest to say so: this host resolves files before rewrites, so negotiating on a header needs routing middleware we have not shipped. Ask for the `.md` path directly. ## Human surfaces - Storefront: https://www.escrova.io - Terms of Service: https://www.escrova.io/terms - Privacy: https://www.escrova.io/privacy - Returns and cancellations: https://www.escrova.io/returns