This is the abridged developer documentation for Surfing Dog
# Overview
> Start here. What the inbox is, how to run it, and how people and AI agents talk to it.
Surfing Dog Inbox is an open-source, self-hostable typed inbox for a business. It receives bookings, orders, quote requests and messages from people and from AI agents, through REST, MCP and email, and turns each one into a typed item with a lifecycle that rules, the owner, or the owner’s own AI can handle. It is fully functional with zero AI. One codebase runs on Cloudflare Workers and on Node. One SQLite database holds everything the business owns. One discovery manifest at `/.well-known/agent-inbox.json` tells an agent where the REST, OpenAPI and MCP doors are. To see what an assistant can already do with your website, before you install anything or after, [check your website for AI agents](/check). ## Where things stand [Section titled “Where things stand”](#where-things-stand) Written on 31 October 2026, for release 0.2.0. Every page in these docs says whether a feature is live. * **The inbox**, open source, on Cloudflare Workers and on Node: typed items with their state machines and append-only events; rules with presets for appointments, trades and shops; REST with an OpenAPI document, a public MCP server and an owner MCP server; sign-in by an emailed link or code, owner keys and OAuth for your own AI; the owner app; the email door; the [spam filter](/docs/spam/); signed [receipts](/docs/receipts/) and [webhooks](/docs/webhooks/). * **Hosted**: we run it for you at an address on your own domain, like `inbox.yourbusiness.com` ([Get your agent inbox](/docs/get-your-agent-inbox/)): Free, or Pro at $10, €10 or £9 a month, whose three AI spam filters are on from the start. * **Connections** to calendars, payments, shops, property systems, ordering systems and job tools: built, not yet verified on a live account ([Integrations](/docs/integrations/)). * **The network** at [network.surfingdog.ai](https://network.surfingdog.ai/v1/stats): the directory, hourly pings (signed where the inbox has its secret key), and each member’s track record, its promises kept, broken and still open, as the business’s own word until the network can confirm them with its customers ([Receipts](/docs/receipts/#networks)). * **Our own inbox**, live at [inbox.surfingdog.ai](https://inbox.surfingdog.ai/.well-known/agent-inbox.json). Still to come: customer records on the network, paused until their privacy safeguards are finished. ## Pages [Section titled “Pages”](#pages) * [Quickstart](/docs/quickstart/): let us host it, deploy to Cloudflare, or run it on your own server, then make your first calls. * [Run it yourself](/docs/run-it-yourself/): one command on your own server, the Docker image on a [platform](/docs/platforms/) you use, or Cloudflare Workers; updates that roll back, and [backups](/docs/backups/). * [Setting a business up for agents](/docs/setting-up-for-agents/): guidance, not rules, from a real installation: the inbox as the front door, or as a thin agent door in front of the tools a business already has; what handles each door; making doors findable; payments; helpdesk bridges. * [Concepts](/docs/concepts/): items and their states, rules, agent policy and trust tiers, networks, and receipts: promises kept, broken or still open. * [Spam filter](/docs/spam/): what it keeps off your desk, what it never loses, what leaves your server and when, and connecting your own AI model. * [Connect your AI](/docs/connect-your-ai/): let Claude, ChatGPT or any MCP client work your inbox through the owner MCP. * [Integrations](/integrations/): every system the inbox connects to, in one table, and what is coming; each one’s steps are in [the docs](/docs/integrations/). * [API](/docs/api/): every public and owner operation, and the conventions they share. * [Manifest](/docs/manifest/): the discovery document an instance publishes. * [Self-hosted vs hosted](/docs/self-hosted-vs-hosted/): an honest comparison. * [Security and privacy](/docs/security-and-privacy/): what leaves the instance, and what never does. * [Contributing](/docs/contributing/): the licence, the toolchain and the rules of the repo. The source is at [github.com/surfingdogai/inbox](https://github.com/surfingdogai/inbox); the plan and the architecture decision records live in its `docs/` folder.
# API
> The public and owner operations, their REST routes and MCP tools, and the conventions they share.
One capability set, several doors. Every operation exists once in the core and is exposed by thin adapters: REST under `/v1` with an OpenAPI 3.1 document, and MCP tools with the same names and the same schemas ([ADR-010](https://github.com/surfingdogai/inbox/blob/main/docs/adr/010-protocols.md)). Whichever door an agent takes, it gets the same item and the same errors. The live OpenAPI document of our own instance: [inbox.surfingdog.ai/openapi.json](https://inbox.surfingdog.ai/openapi.json). Every instance serves its own at `/openapi.json`. ## Public operations [Section titled “Public operations”](#public-operations) No authentication. Anonymous callers receive an `accessToken` when they create an item; agents with an agent key (`sdi_agent_…`) are recognised as a stable principal. | Operation | REST | Does | | ------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `get_business_profile` | `GET /v1/business` | Name, time zone, currency, languages, the item types accepted, `price_negotiable` (`false`, out of the box: the business sets its prices, and a price of the customer’s own goes to a person there; `true` once it takes a customer’s own price as their answer, see below), `return_policy` (schema.org’s `MerchantReturnPolicy`: `merchantReturnDays`, who pays to send things back in `returnFees`, a full refund) and, once the business filled it in, `trader`: who it is (`legal_name`, `address`, `country`, `email`, `phone`, `vat_id`, `complaints_url`). | | `list_services` | `GET /v1/services` | Bookable services with duration and price: a fixed price is for the whole booking unless `price.per` is `person`; `withdrawal`, where the business’s country is in the EU, the EEA, Gibraltar or the UK (or none is set), says whether a customer who paid may withdraw (`standard`) or the exception the law allows (`dated_leisure`, `urgent_repair`, …); absent elsewhere, where no right of withdrawal is claimed. Paged. | | `list_products` | `GET /v1/products` | Orderable products with prices in minor units, and `withdrawal` (`standard`, or `perishable`, `personalised`, `sealed_hygiene`, …) where the business’s country is in the EU, the EEA, Gibraltar or the UK (or none is set), absent elsewhere, each with `availability` (`in_stock`, `limited` for few left by the owner’s own mark, `out_of_stock`, `on_sale`, `preorder` with `available_from`, `backorder`, `made_to_order`; never a count), its barcode (`gtin`, 14 digits) where it has one, `brand`, `mpn`, `condition`, its page on the business’s own site, a picture link and `media`. From catalogue version 2: `price_model` (`fixed`, `from`, `range` with `price_max`, or `quote` with `price: null`), a variant’s `group` and `options`, a “was” price (`list_price`) only while the 30-day rule holds, `unit_price` with its `amount` per reference quantity, `multipack`, `is_bundle`, `available_until`, `age_restricted` with its `label` (“ID checked at handover: 18+”; its `kind` is `null` for an age the menu gives), `fulfil_methods`, `weight`, `shipping_profile` and `return_policy`. Paged, searchable with `q`; `ids` (at most 50) reads the products a network showed you, and `gtin` the products with that barcode, in any of its lengths (`422` for one that does not read as a barcode). | | `quote_shipping` | `POST /v1/shipping/quote` | What shipping a basket (`lines: [{product_id, quantity}]`) to a place (`destination: {country, region?, postal_code?}`) costs by the business’s own rate tables, a read and never an order: for each shipping profile its products ship under, the zone and each service’s fee in minor units, or `null` with why (`id_check`, `currency`, `weight_missing`, `too_heavy`, `over_bands`). Products that do not ship, or whose price is not fixed, come back in `unquoted`. An age-restricted product goes only by a service whose carrier checks ID at handover. | | `check_availability` | `GET /v1/availability` | Free start times for a service between `from` and `to` (at most 14 days). None that has started, and none that starts within the business’s minimum notice (an hour unless it sets another). `party_size` is accepted and does not change the answer yet: a booking takes one place whatever its size. | | `request_quote` | `POST /v1/quotes` | Ask for a price on something custom. Creates a `quote_request`. | | `create_booking` | `POST /v1/bookings` | Request a service at a time. Check availability first. Creates a `booking`, at the business’s price for a fixed-price service, times `partySize` for a service priced per person. A priced booking takes the confirm step first: see [The confirm step](#the-confirm-step). A time that has started is refused (`422` on `payload.startTime`, “That time has passed.”). So is a time the business does not offer, before any summary: outside its hours, on a day it is closed, off the service’s grid, or with no place free (`422` on `payload.startTime`, `details.reason` `outside_hours`, `not_a_start_time` (inside the hours, off the grid of start times `check_availability` lists) or `time_not_free`, and `details.free_times`, the nearest free times before or after it, each with `start_time` and `end_time`, named in the message too). A table for more than the covers set aside is still taken, for a person to seat. One inside the minimum notice that is otherwise free is taken and waits for the business, which does not propose or quote a time inside it either. | | `create_order` | `POST /v1/orders` | Order products. Creates an `order`, at the business’s price for every line that names a product. A priced order takes the confirm step first. | | `get_item_status` | `GET /v1/items/{id}` | The current state of an item you created, in the business’s words and language: what it proposed and waits for you to answer (`offer`), who it waits on (`waiting_on`), what you can do next (`next`), a six-character `reference`, the conversation (`thread`): the business’s replies and your messages, and for a booking or an order `withdrawal` and `refunds` (see [Returns and withdrawal](#returns-and-withdrawal)). | | `pay_order` | `POST /v1/items/{id}/pay` | Pay an order the business accepted, straight from your platform, when `get_item_status` shows `payment.agent` (the business switched on payments from assistants): the one-time Stripe shared payment token (`spt_…`) your platform got for `payment.agent.business_profile` and `payment.agent.platform_amount`, with `agreed.terms_sha` and an idempotency key, on your person’s yes to that total. Charged once, on the business’s own Stripe account, for exactly the order’s total. `200` paid, `202` on its way; a decline (`402 payment_declined`) charges nothing. Listed on MCP only while the business takes it. Every refusal says what to do next, and `details.human` what to tell your person. | | `accept_offer` | `POST /v1/items/{id}/accept`, or `POST /v1/items/{id}/offers/{offer}/accept` | Accept another time the business proposed for a booking, its quote, its changes to an order, or a change it asks for to a confirmed booking or an accepted order (`offer.kind: change`). Takes the confirm step: send the `terms_sha` your person said yes to, and the offer’s id (`offer_id`, or in the path), so an answer to one since replaced does nothing. See [Answering the business](#answering-the-business). | | `decline_offer` | `POST /v1/items/{id}/decline`, or `POST /v1/items/{id}/offers/{offer}/decline` | Decline it: a booking request or an order whose proposed time or changes you decline is closed, as the customer chose; a declined quote closes the request; a declined change keeps the booking or order as agreed, and on a change your person asked for (`requested_change`) it takes it back. Optional `reason`, and `reason_code` (`price_sensitivity`, `timing_deferred`, `quantity`, `delivery`, `returns_policy`, `other`). | | `make_offer` | `POST /v1/items/{id}/offers` | Answer with your person’s own terms, only what they would change (`terms`): another `start_time` (a booking, one of the free times; a quote request, the time it is for), other quantities for the lines of changes the business suggested to an order (`lines: [{index, quantity}]`, 0 drops one), another `delivery_when`, or how many (`quantity`) for a quote request. It goes back to the business as their request. On a confirmed booking or an accepted order it asks for a change to what was agreed: see [Changing what was agreed](#changing-what-was-agreed). A price of their own (`total_price`, `unit_price`), a `note` alone, or a suggestion past the business’s last round, goes to a person there as their message: `202` with `passed_on`, never refused, and what the business proposed still stands. | | `suggest_time` | `POST /v1/items/{id}/counter` | Ask for another time than the one proposed: `start_time`, one of the free times `check_availability` lists. The booking goes back to the business. On a confirmed booking, ask to move it. The same as `make_offer` with `terms.start_time`. | | `provide_details` | `POST /v1/items/{id}/details` | Answer what the business asked (`details`): an item waiting on your details moves on (`200`); on any other item they are kept as your message (`202`, `waiting_on: "us"`). | | `withdraw_from_contract` | `POST /v1/items/{id}/withdraw` | “Withdraw from contract here”: your person withdraws from a booking they paid for or an order, in two steps; where no right of withdrawal is claimed, it asks to cancel or return instead. See [Returns and withdrawal](#returns-and-withdrawal). | | `request_return` | `POST /v1/items/{id}/returns` | Once an order’s goods reached your person: ask to send them back (`reason`: `faulty`, `not_as_described`, `wrong_item`, `changed_mind` or `other`; `lines`; `wants`; `note`). `201` with the return in `linked`. | | `cancel_item` | `POST /v1/items/{id}/cancel` | Cancel an item you created. After the business’s cancellation window, a confirmed booking is cancelled late where the business records late cancellations, and refused where it does not. A booking your person paid for, or an order paid for and not yet sent, is cancelled as their withdrawal while the period runs: never late, and what they paid is refunded. On a return, it drops the return while the goods are still with your person; once they are back with the business, only a person there can. | | `send_message` | `POST /v1/messages` | Start a conversation, or reply on an item you own with `item_id`. A reply on an item waiting on your details answers them: the item moves on. | | `acknowledge_receipt` | `POST /v1/items/{id}/receipt-ack` | Counter-sign a receipt on your item with your agent’s own Ed25519 key, so both sides hold it; or, in a request you sign, name it by `receipt_id`. See [Receipts](/docs/receipts/). | | `verify_customer` | `POST /v1/customers/verify` | Prove you are a customer the business already knows: six digits go to the address it has for you (`202 {sent_to}`), and sending them back as `code` recognises you (`200 {recognised: "strong"}`). On a test item nothing is sent (`202 {sent_to, test: true}`): the code is left on the item for the business to read. | Create calls take `payload` (the typed fields, schema.org names in camelCase), an optional `contact` (`name`, `email`, `phone`, `locale`), an optional free-text `message`, and `idempotency_key`. A booking may leave out `payload.endTime` and `payload.reservationFor.name`: the end is `startTime` plus the service’s length (`durationMin`), the name the service’s own. **Either spelling.** Top-level fields are snake\_case and payload fields camelCase, but `service_id` and `access_token` are taken spelt `serviceId` and `accessToken` too, wherever an operation has them: in a body, in a query and in an MCP tool’s arguments. `/openapi.json` and the MCP tool list show the spelling each operation documents. **The business sets its prices.** A booking of a service priced `fixed`, and every order line that names a product (`productId`, or else `sku`), costs what the business’s catalogue says; an order’s `totalPrice` is then its lines’ prices times their quantities. A different price in the request does not change that: it is kept on the item as `customerStatedPrice` (on the order, and on each line it differs for), for the owner to see, and rules never read it. What the catalogue prices it also names: the service’s name, and the line’s product’s `productId`, `sku` and `name`, replace the request’s. A line whose `productId` and `sku` name two different products, and an order whose total is too large to write down exactly, are refused with `422`. A service priced per person (`price.per: "person"`) costs its price times the booking’s `partySize` (one person when it has none). A line naming no product the business has, a line for a product whose `price_model` is not `fixed` (`from`, a `range`, or `quote`: it is named as the catalogue has it), and a service priced `from`, by `quote` or not at all, keep the price written. Those, and a fixed-price service booked for longer than it lasts, wait for the business to price them: nothing confirms or accepts them automatically. A custom job is a `request_quote`. **Who you carry.** Every public call takes `pass` (a person’s passes, space-separated, at most 8) and `key` (a person’s key, exchanged once for a pass), or the passes in an `Sdi-Pass` header; a `GET` reads only the header. Never put either in a URL. The inbox presents each to the network that issued it, when that network is switched on, and stores none of them: only a hash of a pass, to recognise it again. Create and status answers carry `identity`: | Field | | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `recognised` | `strong` (a customer the business knows, proven), `weak` (the email or phone of one, not proven) or `none`. | | `passes` | Passes to keep, `[{network, pass}]`: a first booking’s pass, or the one a key became. Shown in the answer that made them. | | `verify` | `available` when a one-time code can prove a weak match; `sent_to`, the masked address, once one went out. | | `networks` | What happened at each network: `issued`, `presented`, `person_exists`, `pending`, `unknown_pass`, `revoked`, `pass_requires_signature`, `carries_secret`, `unreachable`, `rate_limited`, `not_enabled`, `cannot_sign`. | | `guide` | `https://surfingdog.ai/for-agents.md` | A first booking or order with an email and nothing carried asks each network that issues for a key, which is emailed to the customer a day later, alone, with a line about the booking network and a link to a page that explains it, where they can switch it off for themselves; nothing about the booking waits for it. A customer who switched it off (or whom the business switched it off for) is never asked about again: no key, no presentation, no receipt or acknowledgement goes to any network about them, nothing a network said about them is read, and their passes still recognise them, from their hash, with no network call; `identity.networks` is then empty. A network that is slow or down leaves the customer `new` there, never refused. How an agent should use all this is [the agent guide](https://surfingdog.ai/for-agents.md). **Signed agents.** An agent may sign any request with HTTP Message Signatures (`sdi-agent/1`, Web Bot Auth compatible: its own key in `Sdi-Agent-Key`, or a platform’s key named by `Signature-Agent`). An unsigned request is served the same; a signature that does not verify counts as unsigned, and the answer says why in `Sdi-Signature: invalid; reason="…"`. A signed request that changes something is kept once: the same signature again is a retry when it comes from the same client with the same idempotency key (answered as the first time), and `401 replayed_signature` otherwise. A pass reference (`sdpass1__`) works only in a request signed by a key delegated to it: the inbox forwards that signature to the network, which checks it again, so a signed request must not carry a secret in its URL or its `Sdi-Pass`, nor cover any header but `Content-Digest`, `Content-Type`, `Sdi-Pass` and its key’s (`Sdi-Agent-Key` or `Signature-Agent`) (the answer says `carries_secret` and nothing is forwarded). Send an access token in `X-Access-Token` on a signed request. `@surfingdog/sdk` signs requests for you. ## Answering the business [Section titled “Answering the business”](#answering-the-business) When the business proposes another time for a booking, sends a quote, or suggests changes to an order (another quantity, a price of its own, a delivery date), it waits for the customer. The status (`GET /v1/items/{id}`, `get_item_status`) then carries: | Field | | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `offer` | `id` (name it when you answer; `null` for one made before offers had ids, until the inbox gives it one), `kind` (`time`, `quote`, `order`, or `change` for a change the business asks for to a confirmed booking or an accepted order), `terms` (`startTime`, `endTime`, `partySize`, `totalPrice`; for a quote `lines`, `notes`, `validThrough`, `creates`; for changes to an order `lines`, `totalPrice`, `delivery`; for a change, the booking or order as it would be), `terms_sha` (the fingerprint of those terms), `deadline` (until when you can answer: see Deadlines below), `obligation_to_pay`, `human` (the terms in the business’s words), `changes` (what differs from what it answers or replaces, as paths into `terms`: `$.startTime`, `$.lines[0].quantity`), `warnings` (`{type: "warning", code: "price_changed", severity: "requires_buyer_review"}` whenever money changed: make sure your person saw the new price) `disclosures` (`held` or `unheld` for a time: kept for them until they answer, or theirs if still free; `withdrawable` when the business may still withdraw it; `personalised_price` when its price was chosen for them) and `expired` (its deadline has passed, before the business’s sweep closes it: it can no longer be accepted, and `next` no longer offers `accept_offer`). `null` when nothing waits for an answer. | | `agreed` | The terms both sides last agreed and their `terms_sha`; `null` before anything was. | | `requested_change` | A change your person asked for to a confirmed booking or an accepted order, while the business has not answered: `terms` (as it would be), `terms_sha` and `until` (when it lapses). What was agreed stands meanwhile; `null` otherwise. | | `waiting_on` | `you` (a time, a quote, changes or a change to answer, details asked for, a payment), `us` (the business has something to do: a request, an order to send, a change your person asked for, a message to answer, such as one your person sent on a confirmed booking that the business has not answered yet), or `null` when nobody has anything to do: closed, or a confirmed booking or stay, delivered goods or an answered message with nothing pending. | | `next` | What the customer can do now: `accept_offer`, `decline_offer`, `suggest_time` (a time, or another time for a confirmed booking), `make_offer` (a quote, changes to an order, a change to an accepted order), `provide_details`, `cancel_item`, `send_message`, each with the business’s label. | | `reference` | Six characters the customer can quote back. | | `thread` | The conversation, oldest first, the last fifty: `from` (`you`, the customer; `us`, the business), `text`, `at`, and `automated: true` on what a rule or the business’s AI sent. The business’s internal notes are never in it. | The item seen by its customer carries none of the business’s own flags, in any answer (a create, the status door, an answer to an offer, a cancellation, a message), and a message the business put aside as spam reads as `closed`. Every sentence (`human`, `offer.human`) is the business’s, in its language (the customer’s `contact.locale`, else the business’s first language), with every time in the business’s time zone and the zone named. **The confirm step.** A booking or an order that carries a price binds the customer — a consumer, unless the business sells only to businesses (`commerce.customers`) — only once they confirmed its summary, as the law asks before an order to pay (CRD art. 8(2)). So `create_booking` and `create_order` without `terms_sha` write nothing and answer `409 confirm_terms`: `details.summary` says what, when, from whom (the business’s legal name and address, once it gave them), the total, the right of withdrawal or why there is none, line by line where they differ (where no right of withdrawal is claimed, which lines cannot be returned, and what the return policies the business wrote give the others, if it wrote any), and that confirming means paying; `details.terms_sha` is its fingerprint and `details.obligation_to_pay` is `true`. Show your person the summary and send the same request again with that `terms_sha` on their clear yes: it is then a first request, whatever idempotency key it carries, and their request binds them. With the fingerprint of other terms (a price the business changed meanwhile, a change in the law it would be made under, or in a return policy it tells) the answer is the same `409` with the current summary and `details.changed: true`. `details.terms` holds the law (`law`) and, where no right of withdrawal is claimed, what each line is told of returns (`returnTerms`), and once agreed `agreed.terms_sha` is this same fingerprint. A request that holds no price of the business’s yet (a line naming no product, a service priced `from` or by quote) or a free one needs no confirm step; the business’s own offer, when it prices it, takes one. On MCP the question is an ordinary result: its text starts “Nothing is sent yet” and `structuredContent.confirm` holds the details. Accepting binds the customer too; when the terms carry a price, to pay it. So `accept_offer` without `terms_sha` writes nothing and answers `409 confirm_terms`, whose `details` hold `summary` (what to show your person), `terms_sha` and `obligation_to_pay`. Show them the summary; accept again with that `terms_sha` only on their clear yes. Different terms, or an `offer_id` the business has since replaced, are `409 offer_changed` with the current `offer`; an offer past its deadline is `410 offer_expired`; a time that is no longer free is `409 slot_taken`, and one that has started `409 guard_failed` (`not_too_soon`). An accepted time confirms the booking; accepted changes accept the order on them. An accepted quote creates what it was for in the same write, already promised: the booking confirmed at the quoted time with its place held, or the order accepted, returned as `linked`. Nothing is ever booked in the past. **Answering with your own terms.** `make_offer` (and `suggest_time`, for a time) sends back only what your person would change; the item goes back to the business as their request, which it takes, answers with another offer, or lets lapse. Out of the box the business sets its prices (`price_negotiable: false`), so a price of their own is not an answer: it goes to a person at the business as their message (`202`, `passed_on` in the business’s words), and what the business proposed still stands until its deadline. So does a suggestion after a few rounds of back and forth (`negotiation.maxRounds`, 3 unless the business sets another, counting the request as the first): never refused, answered by a person. **A price of their own.** Where the business takes one (`price_negotiable: true`, its `negotiation.priceCounters`), `make_offer` may carry it: `total_price` for a time it proposed (with or without another `start_time`), `unit_price` on the lines of changes it suggested to an order, in the business’s currency (`422` otherwise). It is their answer, back with the business, which takes it, answers it, or leaves it; taking it binds them to pay it, as any acceptance does. It still goes to a person as their message, never refused, on a quote, on a product or service the business does not haggle, while the customer has more open negotiations on price than the business allows (`negotiation.perCustomer.open`, 3), after as many prices of their own for the same product or service in `perCustomer.days` (30) as it allows (`perCustomer.priceCounters`, 3), and past the last round. **A price for them.** The business may give a customer whose record with it earned one a better price: a reward it set, applied by the inbox, never below the business’s lowest price and never above its list price. That price is the one in `terms`, in the confirm step’s summary and in every offer; its `human` sentence, the summary and the emails say it was personalised by automated decision-making, with the list price beside it (`disclosures` holds `personalised_price`, and the payload’s `personalised.listPrice` the list price). A price your person states never becomes the price: the list price you write for a rewarded customer is read as theirs. **Deadlines.** Nothing waits forever. A request with the business lapses unanswered after `booking.autoExpireHours` (a booking, 72 unless set; never later than its start) or `negotiation.counterValidHours` (an order or a quote request, 72), counted from when it came in or the customer last answered or was asked something; what the business proposed lapses at its `deadline`: a time a person proposed at its start less the minimum notice, a quote at its `validThrough`, changes to an order at the date they give, and anything with no date, or proposed by the business’s AI or a rule, `negotiation.offerValidHours` (48) after it was made at the latest. A deadline is checked in the write that would take it (`410 offer_expired`), and the inbox’s sweep closes what lapsed within a quarter of an hour (`expired`) and tells the customer in the business’s words. A request made before this release, or one the business wrote down itself (the owner, a shop’s system, an integration key), keeps no clock and never lapses on its own until the customer next answers. When the business takes a request whose clock ran out before the sweep reached it, it goes back to the customer as the business’s offer on the same terms, which they accept: late, never refused. ## Changing what was agreed [Section titled “Changing what was agreed”](#changing-what-was-agreed) A confirmed booking or an accepted order can change without being cancelled, from either side. **Your person asks** with `make_offer` (or `suggest_time`): another `start_time` for a booking, one the business would offer (the booking’s own places count as free to it; the end keeps its length), or, for an order, other quantities of its lines (`lines: [{index, quantity}]`, by index in the item’s `orderedItem`, 0 drops one) or another `delivery_when`; never a price. The answer is `200` with the item: it stays as agreed, and `requested_change` shows what waits for the business, until it lapses (`negotiation.counterValidHours`, and before either start). `decline_offer` takes it back. When the business accepts it the promise moves, and your person is told; when it cannot, or it lapses, it stays as agreed. **The business asks**: the status carries an `offer` of `kind: change` (the booking or the order as it would be, until when to answer, `held` when the new time is kept for them), and `accept_offer` with its `terms_sha` takes it — `obligation_to_pay` only when it asks more of them than was agreed — while `decline_offer` keeps it as it is. It lapses at its deadline, and the promise stays as agreed. If it can no longer be made online by the time your person says yes — payment was asked for since and it would change the order’s total, or a network the business reports to now holds the promise under rules before version 6 — their yes goes to a person at the business as their message (`202`, `passed_on`), never refused, and what was agreed stands until that person answers. A change accepted is recorded as a signed receipt of its own (`kind: amended`, on the item’s `receipts`, with the new dates and the fingerprint of what was agreed), which your agent may counter-sign like any receipt: see [Receipts](/docs/receipts/). A change goes to a person at the business as your person’s message (`202`, `passed_on`, “what we agreed stands”), never refused, when the promise has had the changes it may have (`negotiation.changes.maxPerItem`, three at most), would move more than 90 days from what was first agreed, is past the last round of asking, would change an order’s total once payment was asked for or made, or is held by a network the business reports to, or about to be sent to one, whose rules in force are older than version 6. Its email carries **Accept the change** and **Keep it as it is** links; every confirmation of a booking carries **Change the time**, which opens the free times. **Holds.** With `booking.holdOnPropose` on, a time the business proposes is kept for the customer until they answer: nobody else can book it meanwhile, and their acceptance takes it; at most `booking.maxHolds` (2) held at once per customer, after which a proposed time goes out unheld, theirs if still free. **Emails.** The customer hears from the business by email too, in its name and in the customer’s language (English or Portuguese: the customer’s `contact.locale`, else the business’s first language): an acknowledgement a moment after a booking, order or quote request arrives (a request a rule answers at once gets the answer instead), and an email for every change they need to know of, each with the time in the business’s zone and the zone named, the price and the total, until when to answer, and a six-character reference. A reply to one of them lands on the same item: every email about an item names its anchor first in `References`, and the email door matches `In-Reply-To` and `References` against it, against each email’s own id, and against the id the mail service gave it. An email no person at the business typed — a rule’s, the business’s AI’s, another system’s (an integration key, a shop’s connector) — ends with one line: “This reply was sent automatically. Reply to reach a person.” A system whose request says a person wrote it (`written_by: "person"` on `reply` or `transition_item`, honoured from an integration key) is not; the owner’s AI always is. Every email asks mailboxes not to answer it by themselves (`X-Auto-Response-Suppress: OOF, AutoReply`), and one no person at the business caused says so (`Auto-Submitted: auto-replied` for a reply or the acknowledgement, `auto-generated` otherwise, where the mail service accepts that header; one that refuses it gets the email without it). An out-of-office or a returned email is never answered: one that answers an item is kept on it as a note, and any other is kept as a notice (below). The acknowledgement goes to one address at most three times a day, since anyone can make a request with any address; one held back is kept as not sent (`ack_limit`). A test item (`sandbox`) emails nobody, customer or owner: its emails are kept as not sent (`test_item`). The owner is told of new requests at `notifications.ownerEmail`, else at the address the first owner signs in with; with neither, the email is kept as not sent (`no_address`). The same answers reach a customer by email: the business’s email carries **Accept**, **Decline** and **Pick another time** links (for changes to an order, **Accept** and **Decline**; or **Send the details**), each opening a small page the instance serves at `/c/{token}` in the business’s name and the customer’s language. Opening a link only shows the terms; the page’s button acts, once. A link names one item and one answer, is signed with the instance’s `INBOX_SECRET_KEY`, and lives until the “answer by” date its email shows, and a day more (a quote’s, until it lapses and a day more; a details link, 14 days): opened after that date, the page says why it is too late, and offers the free times. Without `INBOX_SECRET_KEY`, or without a public URL (`INBOX_PUBLIC_URL`, else the app URL in the notification settings), the email asks for a reply instead, and the assistant’s door works as ever. ## Returns and withdrawal [Section titled “Returns and withdrawal”](#returns-and-withdrawal) The business’s own country (`commerce.legal.country`) decides the consumer law the inbox applies, never the buyer’s or where the goods go. A buyer’s own law may give more: the business honours that itself, since the inbox does not track it. Where the business’s country is in the EU, the EEA, Gibraltar or the UK, or none is set, a customer may withdraw from a contract made at a distance within the period the law gives, 14 days (or the business’s longer `returns.days`), without giving a reason (ADR-018 §7): from an order, from its acceptance until 14 days after the goods reached them; from a booking they paid for (`record_payment`; an unpaid booking is a reservation, which they simply cancel), from the booking until it starts or the period ends. The period is counted as the law counts it: the day it starts from is not counted, and one that would end on a weekend or a public holiday (Portugal’s, or England and Wales’s, by `commerce.legal.country`) ends at the end of the next working day, in the business’s time zone. Nothing excepted may be withdrawn from (`withdrawal` on the product or service: made to order, perishable, for a set date, …), and one excepted line of an order keeps no other from its right: each line is told its own, and the others come back within the period as a withdrawal (`request_return` with `lines`). A business that sells only to businesses (`commerce.customers`) gives no such right. An order’s or a booking’s `payload.law` (`eu`, `pt`, `uk`, or `other` where no right of withdrawal is claimed) records the law it is made under, and only the inbox writes it: the one the customer was told before they confirmed it, unless that claimed no right and the business’s country is in the EU, the EEA, Gibraltar or the UK when it becomes a contract (an order the business prices later takes the law of when the customer takes its price, or the business takes the order); otherwise the business’s country’s then. It is part of what the customer confirms (`terms_sha`), and a contract keeps it whatever the business’s country becomes after. One made before items recorded it keeps the right it was told of. `GET /v1/items/{id}` says where it stands: `withdrawal` is `{available, until, label}` (`until` is `null` while the goods are on their way: the customer may withdraw already), and when there is none, `why` (`excepted`, with the business’s words in `reason`, each line’s own where some may still come back; `lapsed`; `business_customer`; `not_paid`; `started`). It is `null` before anything is agreed and after the contract ended, and where no right of withdrawal is claimed (`payload.law` is `other`): a return there goes by what the customer was told of returns, which the business answers. `next` offers `withdraw_from_contract` while it runs, and `request_return` once the goods reached them. **Withdrawing** (`POST /v1/items/{id}/withdraw`, `withdraw_from_contract`) takes two steps, as the law asks. Without `confirm_withdrawal` nothing is sent and the answer is `409 confirm_withdrawal`, whose `details.statement` holds what to show your person: `text` (the statement, “I withdraw from my contract for …”), `name`, `email` (where their copy goes), `contract`, `reference`, `until`, `available`, `label` (“Withdraw from contract here”, or the words of the law the business sells under) and `confirm` (the button, “Confirm withdrawal”). Where no right of withdrawal is claimed the label is “Cancel or return here”, the button “Confirm cancellation”, and the statement asks to cancel (“I would like to cancel …”); the two steps stay. With `confirm_withdrawal: true` (and `lines` to send back only some of the goods, `note`): before the goods went out, the order or the booking ends, neutrally — never a late cancellation — and what was paid is owed back within 14 days, as a refund in `linked`; once they reached your person, the order stands and the goods come back within 14 days, the refund following them. The business emails a copy at once. It is never refused: past the period, where none runs, or for something excepted, it becomes a return under the business’s own policy once the goods reached them (`200`, the return in `linked`), and otherwise your person’s message for a person there (`202`, `passed_on`, saying why where there is a reason). A booking or an order in its first states (a request) has nothing to withdraw from: `409 wrong_state`, cancel it instead. **Returns** (`POST /v1/items/{id}/returns`, `request_return`) are for goods that reached your person: `faulty`, `not_as_described` and `wrong_item` are faulty goods under the legal guarantee (sending them back costs nothing), answered by the business within its `returns.respondHours`; `changed_mind` within the period is a withdrawal, agreed at once; otherwise, a return under the business’s own policy, which it answers. One return of an order is open at a time (`409 guard_failed`, `no_open_return`): write to add to it. Each return is an item of type `refund`, the same customer’s (the order’s access token reads and cancels it), with `kind` (`withdrawal`, `faulty`, `policy`, and `cancellation` or `price_adjustment` for the business’s own), `amount` (what is owed: what was paid for the lines that come back), `goodsBack`, `returnBy`, `refundDue` once nothing more has to come back, and its states: `requested`, `approved`, `goods_received`, `refunded`, `rejected`, `cancelled`. The order or booking lists them in `refunds`, each with its reference and the business’s sentence. **Refunds.** A withdrawal is refunded within 14 days of the notice, or three days after the goods, or proof of sending them, arrive when that is later; anything else `returns.refundDays` (14 at most) after nothing more has to come back. The amount is all of what is owed or more, up to what was paid: anything less needs the customer’s agreement (`409 guard_failed`, `refund_amount`). The owner is told three days before a refund falls due, or, in Portugal, from the tenth day after a withdrawal, since a late one is owed twice over there. A charge-back while a return is open, or after a refund paid late, records nothing against the customer. Returning something never counts in what the business knows of a customer. ## Owner operations [Section titled “Owner operations”](#owner-operations) Authenticate with a Bearer token: an owner key, an integration key (see [Keys and scopes](#keys-and-scopes)) or an OAuth 2.1 access token. The owner app uses its session. | Operation | REST | Does | | ------------------------ | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list_items` | `GET /v1/owner/items` | Items newest first. Filters: `type`, `state`, `needs_human`, `mail_failed` (an email that failed, or one to the customer that was never sent: no address, nothing to send from, no mail service, the day’s acknowledgements used — a test item’s excepted), `open_only` (default true), `sandbox`, `q` (full text, or the six-character reference the customer quotes), `notices` (only notices: emails from systems that answer no conversation, like receipts, account and newsletter mail; listed for a person at the business only, and in no other list). Paged. A notice is moved to the inbox with `POST /v1/owner/items/{id}/move-to-inbox` (a conversation from then on, still a person’s alone) or deleted with `DELETE /v1/owner/items/{id}/notice`, or deleted with every other notice from its sender (its domain, or its address on a domain many people share; 500 a call, `more` says some are left) with `DELETE /v1/owner/items/{id}/notices-from-sender`, by a person at the business. The owner’s AI and an integration key may only mark one as spam (`403 person_only` for any other transition, a reply or a note, and its files), and a person’s answer to a notice still under Notices is refused (`409 wrong_state`, reason `notice`) until it is moved. | | `get_item` | `GET /v1/owner/items/{id}` | One item with its typed fields, events, conversation, receipts, emails and the valid next transitions. `mail` lists every email about it, to the customer and to you, with its `status`: `queued`, `sent` (only once the mail service took it), `retrying` (with `last_error`), `failed`, or `skipped` with a `skip_reason` (`no_address`, `no_sender`, `no_service` when the instance has no mail service and only logged it, `test_item`, `ack_limit` when the address had its acknowledgements for the day); its `body` is the text as sent, with the answer links cut short, so it cannot answer for the customer; a reply in the conversation carries the `delivery` of its email. An email to the customer that failed for good also marks the item as needing you. Each event says who caused it (`by`: `kind`, `id`, and the key’s or AI app’s `name`) and through which door (`channel`). `customer` says who is asking: `match` (`strong`, `weak`, `none`), `possible` (the customer a weak match may be), `known` and `history` (your own record with them), `persons` (per network that presented them: `tier`, `score`, `kept`, `broken`, `businesses`, `since`, `email_proven`, `unusual_use`, and whether it was with this request or `earlier`), `agent` (how the assistant signed) and `networks_off` (when the customer asked not to be known to booking networks: `since`, `via` — `customer`, `owner` or `erased` — and per network what it already had: `receipts`, `open_promises`, which it records as unclosed nine days after they were due, and `person`; `persons` is then empty). | | `transition_item` | `POST /v1/owner/items/{id}/transitions` | Fire one of the item’s events with its `input`, a `reason` and `expected_version`. From your AI, a rule or an integration key without `money:write`, an offer outside [your limits](#your-limits) is kept as a draft (`202`, `drafted` with the limits it is outside as codes) and an acceptance outside them is refused (`422 outside_limits`, `details.breaches`). `confirm` on a booking in `proposed` books the time you proposed, and `accept` on an order in `proposed` accepts it on the changes you suggested, when the customer said yes by phone, email or in person: only a person may (the owner or staff, not their AI, a rule, an integration key or anything on the owner MCP; `403 not_allowed` with `reason: person_only`). `propose` on a booking takes a time that ends after it starts, fits one booking and is free now (`409 slot_taken` otherwise), and replaces a time you proposed before; on an order it takes the lines as they would be (`orderedItem`), an optional `delivery` and `validThrough`, and works out the total. `quote`‘s `validThrough` is optional (`negotiation.offerValidHours` from now). `retract` withdraws what you proposed, only when it said it was subject to your confirmation (`negotiation.binding` off); the customer’s request comes back to you. `record_cancel` records a cancellation the customer asked for (`note`, what they said, required; `askedAt`, when they asked, default now): the customer’s, never the business’s, judged late or not at the moment they asked. `written_by` says who wrote a note the customer gets (see Emails above). When `confirm` or `accept` takes a request whose clock had run out, the answer’s `converted: "request_lapsed"` says it went to the customer as your offer instead. On a confirmed booking or an accepted order, `propose_change` asks the customer for a change (as `make_offer`), and a customer’s change is answered with `accept_change` (the promise moves, its places with it) or `decline_change`; `accept_change` on your own change records the customer’s yes given by phone, email or in person (only a person may, its note kept as yours), and `retract_change` withdraws it when it said it was subject to your confirmation. A change is refused with `409 guard_failed`: `changes_left` past `negotiation.changes.maxPerItem` or 90 days from what was first agreed (a booking’s start or its end), `amendments_live` while a network that holds the promise, or is switched on to be sent it, does not yet have rules version 6 in force, announced not being enough (record the customer’s cancellation and take a new one instead); with rules version 6 in force everywhere, or no network on, a change is recorded, and accepted it issues an `amended` receipt, `total_fixed` when it would change an order’s total once payment was asked for or made (asking for or accepting it), `promise_ahead` once a booking has started. A booking’s change is priced in the booking’s currency, and an order’s delivery date is still to come (`422`). The 90 days count from the dates the promise’s first receipt names, the ones a network holds you to: for an order agreed with no delivery date, `orders.dueDays` after it was agreed, as the setting stood then. Accepting a customer’s change after it lapsed sends it to them as your change on the same terms (`converted: "change_lapsed"`). Returns: `record_withdrawal` records a withdrawal the customer told you of (`note` required; `askedAt`, or `entryId`, their message on the item, whose arrival dates it — what the owner’s AI must name), judged at that moment; `record_payment` on a confirmed booking records a payment or deposit (`paymentRef`, `amount`), which makes it a paid contract the customer may withdraw from; `fulfil` takes an optional `deliveredAt`, and `record_delivery` sets it later: the goods’ period runs from it. `cancel` on a paid order is yours in person (never your AI’s): the order is not fulfilled, and what was paid is owed back at once (a `cancellation` refund, due in `returns.refundDays`); `record_cancel` on one records the customer’s own cancellation, and their refund waits for you (`policy`, `requested`). On a return: `approve` (`goodsBack`, default true when goods were sent; `returnBy`, default 14 days on; `instructions`: `method` `post`, `drop_off` or `collection`, `address`, `note`); `reject` with the reason the customer reads (a person only; never a withdrawal, `409 guard_failed`, `not_withdrawal`); `goods_back` (`receivedAt`), which fixes when the refund is due; `dispute_goods` when what came back is not what you sold (a person only; the note is for the customer, and the refund waits); `refund` (`paymentRef`, `amount`, default what is owed); `record_cancel` when the customer dropped it. A return has signed receipts of its own when the order or booking it refunds was the customer’s: its promise once its date is fixed, and `refund.honoured` or `refund.late` when you record the refund by or after that date (see [Receipts](/docs/receipts/)). | | `make_offer` | `POST /v1/owner/items/{id}/offers` | Your offer, as the item’s state allows: another time for a booking and changes to an order (`propose`), a quote for a quote request (`quote`), a change to a confirmed booking (`startTime`, `endTime` defaulting to its length, `totalPrice`, `validThrough`, `note`) or an accepted order (`orderedItem`, `delivery`, `validThrough`, `note`) (`propose_change`), with `input` as `transition_item` takes it and every check it makes. Catalogue lines go at the customer’s price (the list price, or your reward for them). From your AI, a rule or a key without `money:write`, an offer outside your limits is kept as a draft for you (`202`, `drafted`). | | `open_return` | `POST /v1/owner/items/{id}/returns` | A return a customer asked for by email or phone, on their fulfilled order: `reason` as they gave it, `note` (what they asked, kept for you), optional `lines` and `wants`. Classified as a customer’s is; `201` with the return in `linked`. The owner’s AI may open one. | | `list_offers` | `GET /v1/owner/items/{id}/offers` | Every offer of an item, oldest first: the customer’s request, what you proposed, each answer, what was agreed, and what became of each (`status`: `open`, `accepted`, `declined`, `countered`, `retracted`, `expired`, `superseded`), with its `terms`, `terms_sha`, `round`, `valid_through`, `changes`, `reason_code` and, for yours, what the customer was shown (`shown`); and the item’s `draft`, when automation made one (`get_item` carries it too). | | — | `POST /v1/owner/items/{id}/offers/draft/send` | Send the draft your AI, a rule or another system made outside your limits, as it is: the transition it would have made, made now by you (`draft_id` pins the draft you read). Only you in person, signed in or with a full owner key, never through the owner MCP. `409 draft_stale` once the customer or you moved the item since. | | — | `DELETE /v1/owner/items/{id}/offers/draft` | Drop it, unsent (`?draft_id=` drops only that one). | | — | `GET` and `PUT /v1/owner/catalogue/floors` | Your lowest prices, per product (per unit) or service (per booking, or per person when priced per person): `{floors: [{kind, ref_id, floor_minor}]}`, `floor_minor: null` clears one, and none may be above its price. Only you in person reads or writes them: never your AI, an integration key or a public door, and no catalogue read carries one. | | `reply` | `POST /v1/owner/items/{id}/replies` | Reply to the customer, or leave an internal note with `internal: true`. A reply nobody typed — your AI’s (or anything through the owner MCP), an integration key’s — reaches the customer with the line saying it was sent automatically. One from your AI or a key without `money:write` (unless the key says a person typed it) that names an amount of money neither the item’s terms nor the catalogue hold (a price, a refund), or something off a price, is not sent: it is kept as an internal note and the item is marked for you (`202`, `held`), since an online message with every element of a contract binds the business. `written_by: "person"` from an integration key says its user typed it (no line); `written_by: "automation"` from anyone adds it. | | — | `GET /v1/owner/customers/{party_id}` | One customer (`party.id` on any of their items): every party that is them (merged, or with the same email address; the same phone number only when they gave no email, and only records that gave none either), how many items, open items, messages and emails, `erased_at`, and `networks_off`. | | `export_customer` | `GET /v1/owner/customers/{party_id}/export` | Everything the instance holds about them, as one JSON file: their parties, contacts, identities, network ids, agents, and every item with its events, conversation, emails and receipts. Never a pass, a key, an access token or a link. The owner’s AI may export. | | `stop_customer_networks` | `POST /v1/owner/customers/{party_id}/networks-off` | Stop using booking networks for them, as the customer can from their code email: from now on nothing about them goes to any network and nothing a network said about them is read; their bookings, orders and emails are unchanged. It cannot be switched back on. Optional `item_id`, for the note in its history. A network cannot yet be asked to erase what it already has: `networks_off.networks` says what that is. | | — | `POST /v1/owner/customers/{party_id}/erase` | Erase them, at their request. It cannot be undone, so without `confirm` (or with one that no longer matches: they wrote again since) nothing is erased and the answer is `409 confirm_erase`, whose `details` hold the `summary` and the `confirm` to send. Their names, email addresses, phone numbers, postal addresses, message text, your notes and the emails about them become a placeholder (`Erased customer`, `[erased]`); what stays is the structure: item types, states, times, amounts and lines, the sequence of events, the receipts, what became of each email. Booking networks are switched off for them for good, and a fingerprint of their email address is kept so that holds if they come back. Only the owner (signed in, or with the owner’s own key outside the MCP) or an integration key holding `customers:erase`; never the owner’s AI, never through the owner MCP (`403 not_allowed`, `reason: owner_only`), whatever `security.enforceScopes` says. Erasing again answers `already: true`. | | — | `GET /v1/owner/mail` | Whether email goes out: `service` (a mail service that delivers; without one every email only reaches the log and shows as not sent), `sender` (an address to send from) and `links` (answer links can be signed and opened). Settings shows a banner when one is missing. | | `get_settings` | `GET /v1/owner/settings` | The settings document and its version. Secrets are never returned: a secret that is set reads `(redacted)`, and `redacted` names its path, such as `email.inboundSecret`. Your limits for automation (`negotiation.ai`) and your rewards (`negotiation.rewards`) are left out for anyone but you in person, and `withheld` names them. Writing the document back as read, or without them, keeps them. | | `update_settings` | `PUT /v1/owner/settings` | Change settings: the document you send is merged over the current one, so send only what changes, with `expected_version`. `null` removes a key, so its default applies again. Networks are keyed by origin: `{"networks": {"https://network.example.com": {"enabled": true}}}` adds or switches on one and leaves the others as they are. | | `get_networks` | `GET /v1/owner/networks` | Each network in settings: on or off, whether it gives first-time customers a key (`issue`), what it gets, whether it has verified this instance, the last ping it took, the last error in a few words, the rules it applies, your own `standing` there (`tier`, `score`, `ranked`, and when it said so) from the last signed ping, what became of that ping’s signature (`ping_signature`), and its receipts published, waiting, held, refused and `withheld` (never to be sent: their customers asked not to be known to booking networks). `rules.v2` and `rules.v6` say whether it takes outcomes (rules version 3) and changes and refunds (rules version 6), in force or announced; `held` counts receipts waiting for it to: acceptances and outcomes before version 3, amendments and refunds before version 6, and every receipt of a promise that changed until version 6 is in force. While a host holds the networks (a hosted inbox before it is live at your own address), each network has `held: {on_when_released}`: whether it comes on when the inbox is live, which you choose meanwhile with `networks..resume` (or `enabled`, the same while held; your AI may only set it to false). `reportable` says whether a network could verify this inbox at its address now (an https origin on a public host, port 443). | | — | `POST /v1/owner/receipt-keys/rotate` | A new key to sign receipts with, for when a copy of the old one may be in the wrong hands: the old key is retired, your inbox signs nothing more with it, and it stays published so every receipt it signed still verifies. Answers `{retired, active}`; `409 wrong_state` with no key yet. Only you in person, never your AI or an integration key (`403`). | | `list_api_keys` | `GET /v1/owner/api-keys` | The owner and integration keys: name, scopes, last use, and every call each made outside its scopes. Never a key itself. | | `create_api_key` | `POST /v1/owner/api-keys` | A named, scoped, revocable integration key. The key is in this answer and in no other. | | `revoke_api_key` | `DELETE /v1/owner/api-keys/{id}` | Revoke a key at once and for good. | | — | `GET /v1/owner/download`, `POST /v1/owner/download/code`, `POST /v1/owner/download` | Download the inbox. The `GET` says whether it can be done here (`200`, or `501 not_available`) and sends nothing. The first sends six digits to your own address (the one you sign in with; with a full owner key that names nobody, `notifications.ownerEmail`, else the first owner’s), which work once, for ten minutes and five tries; a code sent earlier keeps working until one of them is used, and at most five are sent an hour. The second takes them as `{"code": "…"}` and answers a zip: `inbox.db`, the whole inbox as one SQLite file, without who is signed in, sign-in links, keys, connected AI apps, rate limits, queued jobs or any sealed secret (webhooks and connectors come switched off, receipt keys without their private halves); `README.txt`, which lists every table and what was left out; and `manifest.json`, the same for a program, with the file’s SHA-256. With `{"code": "…", "receipt_keys": true}` it also holds `receipt-keys.json`, the key your receipts are signed with now (its private half in the clear: keep it as safe as a password), once there is one; ask for it only when you move to a server of your own. You are told by email after each download. The file opens in an inbox you run on Node; `node server.mjs import-receipt-keys receipt-keys.json` there seals the key with that inbox’s own `INBOX_SECRET_KEY`, and it signs as this one did. Without it, it signs with a key of its own and still publishes the old public keys, so what was signed before still verifies. The `GET` also says `leaving: true` while a hosted inbox closes. Only you in person, never your AI or an integration key (`403`). An inbox on D1 cannot copy its database from inside: it answers `501 not_available` and sends no code, and `wrangler d1 export` copies the database instead. | Setup (profile, services, products, opening hours, closed days, rules) is under `/v1/owner/profile`, `/services`, `/products`, `/availability` and `/rules`, with the MCP tools `update_profile`, `upsert_service`, `upsert_product`, `set_opening_hours`, `set_closures`, `upsert_rule` and their neighbours. A preset’s rules (`POST /v1/owner/rules/presets/{key}`, `apply_rule_preset`) take `confirm`: `rules`, the default, keeps the rules that confirm a small booking or accept a small order on their own, and `owner` leaves them out, so you confirm each booking and accept each order. All of setup at once is a setup plan, `POST /v1/owner/setup/plan`: `profile` (name, time zone, currency, languages), `hours`, `closures`, `services` (matched to yours by name: one that exists is changed, one that does not is added), `preset` with `booking.confirm` (`owner` unless it says `rules`), the public profile under `directory`, kept out of every directory (`listed` may only be `false`, `url` only `""`), the `doors` it expects to be on (each switched on in `doors` where it would stay shut: asking for a price with nothing listed, or a door you switched off), `doors_elsewhere` (bookings at a booking platform or orders in your own checkout, as `[{"door": "booking", "url": "https://…", "name": "…", "platform": "fresha"}]`, kept as `doorsElsewhere` with its tracking taken off and its `platform` only when the page is on it ([Booked or ordered elsewhere](/docs/connectors/elsewhere/)); `[]` brings both back to the inbox), and `sources`, where each detail was read (`source_url`, `checked_at`, and `confidence`: `declared` in the site’s structured data, `read` from its words, or `confirmed` by you), kept in `setup.sources`. Each part is written only when it differs, so applying the same plan twice changes nothing the second time, and a plan cut short is finished by applying it again. A service’s price goes in only with `price_confirmed: true`; a plan never holds products, feeds or legal details (`422`). With `?dry_run=true` nothing is written. Either way the answer says it in `sentences` (where the details were read, what applying does, what agents can do and who confirms a booking), with `changed` and `doors`: the doors on afterwards, and each expected door that stays shut with what opens it. A dry run is any owner key’s or AI app’s; applying is yours in person (`403` otherwise). On Node, `node server.mjs apply-plan plan.json [--dry-run]` does the same from the command line. Webhooks, deliveries and the event stream are in [Webhooks](/docs/webhooks/); product feeds (`list_feeds`, `add_feed`, `import_feed_now`, `remove_feed`) in [Feeds](/docs/feeds/). The OpenAPI document lists every owner operation with its query parameters, its response schema and its security. ## Keys and scopes [Section titled “Keys and scopes”](#keys-and-scopes) Give every system that calls the inbox its own key: Zapier, a shop, a till, a form plugin. Create one in the owner app under **Settings → Keys**, or with `POST /v1/owner/api-keys`:
```json
{ "name": "Zapier", "preset": "automation" }
```
The answer holds the key (`sdi_own_…`) once. Send it as `Authorization: Bearer …`. It works on `/v1/owner` and `/mcp/owner` until it is revoked or reaches its optional `expires_at`. | Preset | Scopes | For | | --------------- | ------------------------------------------------------- | ---------------------------- | | `automation` | `inbox:read inbox:write events:read` | Zapier, Make, n8n | | `shop_sync` | `catalogue:write inbox:read inbox:write events:read` | A shop or a point of sale | | `calendar_sync` | `availability:write inbox:read inbox:write events:read` | A calendar or a booking tool | | `read_only` | `inbox:read events:read settings:read` | Reports and dashboards | The scopes are `inbox:read`, `inbox:write`, `events:read`, `catalogue:write`, `availability:write`, `settings:read`, `settings:write`, `setup:run` (rules), `integrations:write` (webhooks and feeds; listing and syncing connected calendars and payment platforms, whose connecting, changing and disconnecting are the owner’s in person), `keys:write`, `customers:erase` (erase one customer) and `money:write` (record payments and refunds, and price offers and quotes, as your shop or till does). The last two are in no preset: only the owner gives them, and an AI app is never offered them. OAuth clients ask for the same names, but for those two. A key never carries `keys:write`: an integration key cannot mint keys. A key without `money:write` is held to your limits as your AI is: it records no payment, failed payment, charge-back or refund and gives no payment link (`403 not_allowed`, `money_recorded`), and what it offers is judged against your limits. Keys that could move items along before the limits existed (`inbox:write`, or everything) were given `money:write` when the instance was upgraded, so they keep working as they did. **The owner’s AI and returns.** It may open a return (`open_return`), record a withdrawal naming the customer’s message (`record_withdrawal` with `entryId`), and approve a return inside the owner’s policy — faulty goods, or within `returns.days` with nothing it takes back excepted; where no right of withdrawal is claimed, only within what the customer was told of returning those lines as the order was made (from the return policies the owner wrote then, kept on the order as `payload.returnTerms`), with nothing it takes back excepted — with the goods coming back, while `negotiation.ai.mayAuthorizeReturnsInPolicy` is on (the default; `409 guard_failed`, `return_allowed` or `return_outside_policy`, `draft_for_owner`). Refusing a return, disputing what came back, cancelling a paid order (`403 not_allowed`, `person_only`), agreeing a refund with nothing sent back (`owner_money`), recording a payment or the refund itself and changing `returns` or `commerce` are the owner’s. A product’s or service’s `withdrawal` is the owner’s (or staff’s) to set, never the AI’s or another system’s key (`403 not_allowed`, `owner_only`); feeds never write it. **The owner’s AI and money.** The owner’s AI — an AI app the owner connected over OAuth (actor `owner_ai`), or anything that comes through the owner’s MCP, even with a full owner key — confirms and proposes times, and does with money only what [your limits](#your-limits) let it. It never records a payment, a failed payment, a charge-back or a refund, asks for payment with a link of its own (`paymentUrl`), changes a service’s or product’s price or the business’s currency, publishes a priced product or service, connects or disconnects a feed, or writes, changes or switches on a rule that sends a quote, or whose words name an amount or something off a price (`403 not_allowed`, reason `owner_money`; nor does a key without `money:write`); it cannot confirm a booking or accept an order whose request held a price the catalogue does not give (`409 guard_failed`, `business_priced`). Nor does it, a rule or a key without `money:write` accept or offer an order that holds a product off sale or more of one than its counted stock, or accept or ask a change to an agreed order that adds units of a product off sale, or more units of one than its counted stock (`409 guard_failed`, `in_stock`, `draft_for_owner`; a rule flags the order for a person instead); a change that moves the date, or asks for fewer, adds nothing, and an order with a line for a product off sale is refused when it is made, whoever makes it (`400 invalid_input`, `payload.orderedItem.N`, “not on sale”). A booking or order it makes through the public doors is priced from the catalogue, as a customer’s is. A product, or a service with a price, that it adds is saved unpublished (`active: false`) for the owner to check and publish. Each refusal carries `details.draft_for_owner: true`: the AI leaves the owner a note (`reply` with `internal: true`) with what it suggests. Changes to what was agreed follow the owner’s settings (`negotiation.ai`): it accepts a customer’s change to a free time only while `mayAcceptChanges` is on (the default), before `negotiation.changes.customerCutoffMin` (else the cancellation window) and, for an order, at the catalogue’s prices (`409 guard_failed`, `change_allowed` or `business_priced`, `draft_for_owner`); what it asks for is an offer, judged by your limits. While the cancellation window is also that cutoff (no `customerCutoffMin` of the owner’s), the AI may lengthen `booking.cancellationWindowMin` but never shorten it (`403 not_allowed`, `owner_in_person`); likewise it, or a key, may lower `orders.maxValueWithoutApprovalMinor` but never raise it or set it to none. What it proposes holds at most `negotiation.offerValidHours`, and says so (a quote or changes it sends with a later date carry that one instead). **The owner’s AI** — an app connected over OAuth, or anything on the owner MCP, whatever key it holds — reads what customers write, and a customer can write instructions. So nothing that changes where the inbox sends its data is the AI’s, whatever its scopes: it gets `403 not_allowed` with `details.reason: "owner_in_person"` and `details.ask_owner: true` for creating or revoking a key, adding, changing, rotating or removing a webhook endpoint (pausing one, `active: false`, is allowed), changing `notifications.ownerEmail`, `notifications.appUrl`, `email.fromAddress`, `email.fromName`, `email.replyTo` or `doorsElsewhere` (where customers are sent to book or to order), switching webhooks or test mode on, and for everything below. The owner in person does these, and so may an integration key that holds `integrations:write` (endpoints) or `settings:write` (those settings) by name. Only the owner in person changes `negotiation` (how long offers hold, the rounds automation may go, whether what the business proposes binds it), `returns` (the return policy), `commerce` (who the business sells to, and who it is), `email.inboundSecret`, `integrations.webhooks.allowPrivateTargets`, `identity`, `customers.otp`, `doors` (what agents can do) and the `security` section, or switches a network on, lets it issue codes or shares more with it: a network is sent customers’ email addresses. A document written back with those unchanged is accepted and leaves them as they are. `security.aiMayCreateKeys` is retired and grants nothing. A reply or a transition note from the AI that names another customer’s email address or phone number, or carries a key or secret, is refused too (`details.reason: "would_leak"`). What customers wrote reaches the AI after each tool’s own text, in a block drawn with a random boundary, and in `structuredContent.untrusted_content`. **Scopes are enforced.** A call outside a key’s scopes, or outside an AI app’s granted scopes, is refused: `403 not_allowed` with `details.reason: "outside_scopes"`, the scopes that would let it through (`required_scopes`) and those it holds (`held_scopes`), and nothing is done. A call only you make in person (making or revoking a key, connecting, changing or disconnecting a system), or one that needs a scope no AI app is ever given, says so instead, with `details.reason: "owner_only"` and no scope to ask for. Each such call is recorded too: `list_api_keys` shows each key’s calls outside its scopes, with the scope it needed and whether it was refused, and the same for AI apps. That is **Refuse calls outside a key’s scopes** in Settings → Keys (`security.enforceScopes`), on out of the box, and on an inbox set up before it was, unless its owner had switched it off. Off, such a call goes through and is only recorded. A system that needs more gets a key of its own with those scopes; an AI that sets things up asks for the scopes it needs when it connects. **Limits.** An integration key has a generous bucket of its own, about ten calls a second, so a loop between the inbox and another system cannot run for ever. The owner’s own session, keys and AI are not limited. ## Your limits [Section titled “Your limits”](#your-limits) What your AI, your rules and an integration key without `money:write` may agree to on their own is yours to set, in code rather than in a prompt (`negotiation.ai`: in the owner app, **Settings → Prices, and what your AI may agree to**, and each product’s or service’s lowest price under **Settings → Services**). Out of the box it is time yes, money no: any free time within seven days of the one asked for, and no price but the customer’s. | Limit | Breach | Out of the box | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -------------- | | A catalogue line under your lowest price for it (`GET`/`PUT /v1/owner/catalogue/floors`), or under the customer’s price less `maxDiscountPct` | `below_floor` | no discount | | A catalogue line above the customer’s price: a surcharge, or holding back a reward | `above_list` | always | | Answering a customer’s own price with another: automation takes theirs, or leaves it to a person | `counter_priced` | always | | A price the catalogue does not give: a quote, a line of its own, a longer booking (`mayPriceCustom`); or keeping a price the customer’s request set; or taking a customer’s own price for what a person priced | `custom_line` | never | | A time further than `maxTimeShiftMin` from the one asked for, or one you would not offer (outside the opening hours, on a closed day, off the grid) | `time_moved` | 7 days | | A delivery later than `maxDelayDays` after the one asked for | `delivery_later` | 0 days | | Dearer for the customer than what you last offered them in this negotiation | `worse_than_before` | always | | Past the last round (`negotiation.maxRounds`) | `rounds_exhausted` | 3 | | Asking a customer for a change to what was agreed (`mayProposeChanges`) | `change_not_allowed` | never | | An order above the value you accept in person (`orders.maxValueWithoutApprovalMinor`) | `over_approval_value` | none | | A note, a reply or the name of a line, naming an amount of money you have not offered (a catalogue price counts only beside the name of what it prices), in figures or in words, or something off a price (a percentage off, half price, free of charge) | `amount_named` | always | An **offer** outside them — a time, a quote, changes to an order, a change to what was agreed — is not sent: it is kept as the item’s **draft** (`202`, `drafted: {id, breaches}`), the item is marked for a person, and you are emailed at most once an hour per item. `get_item` and `list_offers` show it with its breaches and whether the item has moved since (`stale`); you in person send it as it is (with the notice beside a price the inbox chose for the customer), make your own offer, or drop it; neither your AI nor a key sends or drops one. A newer draft replaces the last. An **acceptance** outside them (`confirm`, `accept`: a customer’s own price under the floor or above the list price, an order above the value you accept in person) is refused, `422 outside_limits` with `details.breaches`. A **reply** naming money you have not offered is kept as an internal note (`202`, `held`); a note with a transition that is not an offer is refused as an acceptance is. Breaches are codes, never numbers: no error, draft or sentence tells automation where a limit is, so no customer’s message can get it to recite one. Refunding with nothing to send back is automation’s only up to `maxRefundMinor` for an order, counting what was already agreed on it with nothing back (none out of the box: `refund_over_max`, else `owner_money`), and approving a return outside your policy is yours (`return_outside_policy`). You in person are never held: your offers go as you make them, at the prices you type. Only you change `negotiation`, the floors, and whether a product or service may be haggled (`negotiable` on `upsert_product` and `upsert_service`, `403 not_allowed` for anyone else; feeds never write it). **Rewards.** A better price for a customer whose record earned it, set by you, applied by the inbox (`negotiation.rewards`, keyed by a name of yours): `{"regulars": {"if": {"path": "customer.completed", "op": "gte", "value": 3}, "pct": 5, "only": null, "says": "Thank you for coming back."}}`. `if` is a rule condition that reads only what the customer earned — your own history with them (`customer_known`, `customer.*`) and the standing their assistant showed (`person_trusted`, `person_tier_on`, `person.*`), combined with `all` and `any`; `not`, how sure the inbox is of who they are (`customer.match`), what they have open now (`customer.open_bookings`), anything else, and anything about where someone lives or comes from are refused when saved (`422`, the field to fix), and so is a condition a customer with no record here would meet (“no no-shows”, “not trusted”): a reward only ever lifts, so it is never a price for everyone but those with something against them. A request the inbox priced with a reward stays at that price if you change the rewards before it is confirmed. `pct` is 1 to 50 off the list price, rounded to the cent in the customer’s favour, never below your lowest price; `only` names the products and services it covers (`null`, all); `says` is a line for the customer after the notice, checked for words a customer should not read from you. The best matching reward applies, never several. Every price a reward or automation chose below the list price carries the personalised-price notice. A rule that reads a customer’s standing still only confirms, accepts, asks for payment, flags or replies; one that reads where they live or come from may not set a price or terms (`422 geo_terms`). No rule reads your limits, your rewards or a secret from the settings: what a rule can read, a test of it can tell your AI. ## Conventions [Section titled “Conventions”](#conventions) **Idempotency.** Every write takes an idempotency key: in the `Idempotency-Key` header or as `idempotency_key` in the body on REST, as the `idempotency_key` argument on MCP. That includes the owner’s setup writes (services, products, hours, rules, settings, webhooks, feeds, keys). Same key and same request: the stored answer is replayed with status `200` and `Idempotent-Replayed: true`, and nothing is done twice. Same key and a different request: `422 idempotency_mismatch`. The owner’s and the owner’s AI’s keys are scoped to the business, so a retry through MCP of a request first sent through REST is the same request; an integration key’s keys are its own, so two systems that pick the same key never collide; a customer’s keys are scoped to the customer and the door. An answer that carries a secret (a signing secret, a key) is stored sealed, and a replay shows the secret only to whoever made the first request. A customer without a key is known only by its address and user agent, which many people share, so the access token and the passes a first booking, order or message hands back are given again only to a retry that proves it made the item: one sent with the same request and the same random idempotency key (a random UUID, as `crypto.randomUUID()` makes, or at least 32 random hex digits) within the key’s 30 days, not through a network, and a key nobody else has sent; or one signed by the agent’s own key that made the item (not a platform’s key, which signs for all its users). It is the same token, so a copy of the first answer keeps working. A guessable key (`booking-1`, a counter, a date, a time-based UUID, a pattern, an example UUID copied from a manual) can be sent by anyone behind the same address, so its retry says `accessTokenWithheld: true`, and on MCP says so in words. Never make a key from what you send, such as a hash of the body: the inbox cannot tell it from a random one, so anyone behind the same address who sends the same request with it gets the token back. Make every key a fresh random UUID. A retry of a write on an item is answered only to its owner (its access token, or the party a pass or key names). Keys are kept for 30 days: they are for retries, not a record. **Who did it.** Every event, in the history, the [event stream and webhooks](/docs/webhooks/), carries `actor` (`kind`: `owner`, `owner_ai`, `integration`, `connector`, `rule`, `system`, `customer_agent` or `customer_human`; `id`; and `name` for a key or an AI app), the `channel` it came through and `sandbox`. A customer’s id is never given out. A two-way sync skips the events whose actor is its own key. **Access tokens.** A create call from a caller without an account returns `accessToken` (see Idempotency for a retry whose answer was lost). Send it back in the `x-access-token` header (best: a URL ends up in logs), or as `access_token` (or `accessToken`) in the body or the query, to read, answer or cancel the item: every door under `/v1/items/{id}` takes it the same three ways; on a signed request, only the header or the body. A request with none is refused `403` with `details.reason` `no_access_token` and a message that says where it goes. It is a capability, not an identity: whoever holds it may act on that one item. A person’s pass does the same for their own items once the business knows them by it (their first booking with it, or a proven match): with the pass in `Sdi-Pass`, the status, answer, cancel and acknowledge doors need no token. **Versions.** Item views carry `version`. Send `expected_version` on a transition to be refused with `409 version_conflict` if a colleague, a rule or an agent moved the item first. **Errors** are RFC 9457 problem documents, `application/problem+json`, with `type` (`https://surfingdog.ai/problems/`), `title`, `status`, `detail`, `code` and, for input problems, `fields` listing `path`, `problem` (`missing` or `invalid`) and `message`. Codes and statuses: `invalid_input` 422, `not_found` 404, `not_allowed` 403, `wrong_state` 409, `unknown_event` 400, `guard_failed` 409, `slot_taken` 409, `version_conflict` 409, `idempotency_mismatch` 422, `unauthorized` 401; for one-time codes `nothing_to_verify` 409, `already_verified` 409, `bad_code` 422, `code_expired` 422, `too_many_attempts` 429; `replayed_signature` 401; `positive_only` 422 for a rule that reads a customer’s standing and would refuse, cancel or expire; for a customer’s answers `confirm_terms` 409 (also a priced booking or order not yet confirmed), `offer_changed` 409, `offer_expired` 410 (a quote, a time or changes past their deadline) and `no_offer` 409; `confirm_withdrawal` 409 for a withdrawal’s statement not yet confirmed; and `confirm_erase` 409 for an erasure not yet confirmed. A refusal a customer may hear through their assistant (`offer_changed`, `offer_expired`, `no_offer`, `slot_taken`, `not_too_soon`) is worded in the business’s voice and the customer’s language. In MCP the same document comes back in `structuredContent.error` with `isError: true`, and the text names the fields to fix. **Paging.** List endpoints take `cursor` and `limit` (1 to 100, default 50) and return `items` and `next_cursor`. Pass `next_cursor` back as `cursor` for the next page; it is `null` on the last one. **Money and time.** Amounts are integers in minor units with an ISO 4217 `currency`. Instants are ISO 8601 with an offset. Durations are minutes. **The catalogue for networks.** While you list your products on networks (Settings → Networks, on unless you switch it off), `GET /v1/catalogue/changes` serves them to any network that reads them, page by page in the order they changed, each one listed or withdrawn, and `GET /v1/catalogue/items?ids=…` by id. Both are signed in the `Catalogue-Signature` header with your receipt key, and say only what your own doors already show, never a stock count, your lowest prices or where a product was imported from (protocol §4.18). Off, both answer `404 not_listed`, and your own doors list your products as before. Your view of it is `GET /v1/owner/catalogue`: the switch and why it cannot work, what is listed, each product’s notes in plain words, and each network’s answer. A barcode your AI proposed becomes the product’s barcode with `POST /v1/owner/products/{id}/barcode`, and the one-time offer to use imported codes that look like barcodes is answered with `POST /v1/owner/catalogue/sku-barcodes {"use": true}` or `false`; both, the switch, the tax chooser, “List my products here” and a product’s `listed` and `no_gtin` are yours in person, never your AI’s or another system’s key. **Catalogue version 2.** The feed serves both versions, and the manifest says so (`catalogue.versions: [1, 2]`, and `catalogue.policies`): a network names the one it reads (`version=2`), and a read that names none is version 1, as before. Each product is made once, as version 2 has it, and a version 1 page withdraws what version 1 cannot say truly (a price not `fixed`, a pre-order, a back-order, made to order, an age, its own tax basis); cursors belong to their version (`422 cursor_invalid` for the other’s), and `422 version_unsupported` and `limit_invalid` say what was wrong by code. Every item is read back by the spec’s own reader before it is served: one a network would hold is served withdrawn, and the reason shows on your `GET /v1/owner/catalogue` in its own words (`catalogue_problem`, with its code). Your shipping profiles and return policies are published signed at `GET /v1/catalogue/policies`; you set them with `PUT /v1/owner/catalogue/shipping-profiles/{id}` and `PUT /v1/owner/catalogue/return-policies/{id}` (and take one away with `DELETE`, refused while a product names it), read them at `GET /v1/owner/catalogue/policies`, and every product without one of its own uses the default. Where your business’s country is in the EU, the EEA, Gibraltar or the UK, or none is set, your returns settings are the default return policy, `standard`, until you write its terms, and no policy is published shorter than the 14 days the inbox applies there; elsewhere only a policy you write is published (`unpublished` on your own read lists the one that is not), and with none written the document has no return policy and your profile none, so nothing is said of returns. Shipping is your own rate tables (flat, per item, by weight, by order value, free over a value): you print your own labels and no carrier is called. A return policy under 14 days, or no returns, is refused where the business’s country is in the EU, the EEA, Gibraltar or the UK, and with no country set (and so is moving the business there while one is shorter); elsewhere it is shown as it is, and the items say nothing of a right to withdraw (`withdrawal`), since the business’s own country decides the law the inbox applies. A buyer’s own law may give more, which the business honours itself. Where the inbox applies a right of withdrawal, a return for a refund stays open as long as the policy you publish for each product says, never less than your returns settings; elsewhere, as long as the customer was told as the order was made. Variants are a product group (`POST /v1/owner/product-groups {title}`, then each variant’s `group_id` and `options` on `PATCH /v1/owner/products/{id}`); a feed’s `item_group_id` comes in as one. How a price is meant, a “was” or unit price, its own tax basis, how it is available, few left, a pack or a bundle, an age, how it reaches the buyer, its weight and its shipping and return terms are yours in person, never your AI’s or another system’s key (`403 not_allowed`, `owner_in_person`); a group and its options are not. A “was” price is refused unless the product sold at or above it for the 30 days before its price went below it (`list_price_never_seen`, `list_price_history_short`, `list_price_sold_below`), and a write that makes the price of a product on sale and listed 0 is refused: a price on request is `price_model: "quote"`. One kept off networks may be 0, and one priced 0 before is kept as it is, off networks. Tobacco and nicotine, cannabis and weapons stay on sale at your own doors and are kept off networks. An age your menu gives (a beer at 18) is labelled at your own doors and kept off networks until you give the product an age restriction with its kind (`age_kind_unknown` on your catalogue page). **Caching.** The profile and the manifest are cacheable for five minutes, services and products for one minute. Everything else is uncacheable. **Sandbox.** An instance in test mode, a request with the `x-sandbox: 1` header, or a `sandbox.` hostname marks the item as sandbox: same machines, and nothing leaves the inbox. No email goes to the customer or the owner (each is kept as not sent, `test_item`), no network is asked for a key or shown a pass, a one-time code is left on the item as an internal note instead of being emailed, and no receipt is signed. While test mode is on, that holds for every request, real customers’ included. ## MCP [Section titled “MCP”](#mcp) Both servers are stateless per request, so a client connects with a plain `POST` per call. Tools carry annotations (`readOnlyHint`, `idempotentHint`, `destructiveHint`) and short server instructions: * public: start with `get_business_profile`, then `list_services` or `list_products` (and `quote_shipping` for what delivery to a place costs); for a booking, `check_availability`, then `create_booking` at one of its free times with an idempotency key you generate and keep (a fresh random UUID); keep the access token; when `get_item_status` shows an `offer`, relay `offer.human` as the business said it and call `accept_offer` with its `terms_sha` and `offer_id` only on their clear yes to the summary, otherwise `decline_offer`, or `make_offer` with only what they would change (`suggest_time` for another time; a price of their own goes to a person at the business, and what it proposed still stands); when it waits for details, `provide_details`; a priced `create_booking` or `create_order` answers with the summary to confirm, and is sent again with its `terms_sha` on the person’s yes; `withdraw_from_contract` shows the statement first and withdraws on `confirm_withdrawal`, and `request_return` asks to send goods back; `get_item_status` also carries the conversation (`thread`), and a reply marked `automated` was not written by a person. The public server is named after the business, and its answers end with the terms, until when, and the choices, since many assistants read only a tool’s text. `accept_offer` without `terms_sha` is not an error: its text is the question to put to the person; * owner: `list_items` shows what needs a person, `get_item` shows the full story, `transition_item` moves an item with one of the events its view lists, `make_offer` proposes as the item’s state allows and `list_offers` shows every offer and answer so far, `reply` speaks to the customer (with a line saying it was sent automatically) and `get_item` shows whether each email went out; never invent facts about availability or prices; give each system its own key; send an idempotency key with every write; a customer’s cancellation by email or phone is `record_cancel` with their words, never `cancel_by_business`, and a withdrawal they wrote is `record_withdrawal` naming their message; a return they asked for by email or phone is `open_return`; only a person records a yes to a proposed time given by phone; a customer who asks what the business holds about them gets `export_customer`, one who asks not to be known to booking networks `stop_customer_networks`, and one who asks to be erased is the owner’s to erase, in the app; money is the owner’s (below). ## Discovery [Section titled “Discovery”](#discovery) * `/.well-known/agent-inbox.json`: the [manifest](/docs/manifest/), listing the REST, OpenAPI and MCP doors. The email door is not in it. * `/.well-known/oauth-protected-resource/mcp/owner` and `/.well-known/oauth-authorization-server`: OAuth 2.1 metadata for the owner MCP. * `/healthz`: `{ "ok": true, "version": "…" }`. * `/c/{token}`: the page a link in the business’s email opens. `GET` shows, `POST` acts; never cached, never indexed, no referrer. The code email’s own link opens the page about the booking network for that customer, with the button that switches it off for them. * `/c/privacy?l=en|pt`: the same page for anyone, in the business’s name: which booking networks the instance asks for a first-time customer’s code, what they keep, what it is for and how to stop (the code email’s link, or a reply to any email). * `POST /v1/email/inbound`: raw MIME from a mail provider’s webhook or a forwarder, with the instance’s shared secret in `X-Inbox-Email-Secret`. ## SDK [Section titled “SDK”](#sdk) `@surfingdog/sdk` (MIT, no dependencies) is on npm. For a business’s systems it carries the [webhook verifier](/docs/webhooks/) and the event types. For a customer’s agent it carries the person’s strings (`parseCredential`, `keepPasses`, `sdiPassHeader`, `passFromKey`), request signing (`generateAgentKey`, `signRequest`, and `requestSignInCode`, `signIn` and `delegate` for the one-time setup at a network) and receipts (`verifyReceipt`, `signAck`). Every one is checked against the published vectors. The typed REST client, generated from the OpenAPI document, lands beside them; until then, any OpenAPI client works.
# Backups
> What to keep (one SQLite file and one secret key), the nightly backups, a restore, and Litestream for copies within seconds.
Everything an inbox holds is in one SQLite file: requests, threads, settings, keys, receipts, and the secrets it keeps sealed. Two things restore it whole: 1. **A backup of the database.** A copy made by the inbox itself, never a copy of the file while it runs, which can be caught halfway through a write. 2. **`INBOX_SECRET_KEY`.** The sealed secrets (calendar and payment connections, webhook secrets, the key that signs receipts) open only with it. Without it a backup restores every request and setting, and those connections have to be made again. Keep it apart from the backups, in a password manager. A backup on the same disk as the inbox protects from mistakes, not from losing the server: copy backups somewhere else too. ## On your own server [Section titled “On your own server”](#on-your-own-server) The installer makes a backup every night at about 03:30, server time, and keeps the last seven. Any time:
```bash
sudo inbox backup
```
Each is one file in `/opt/inbox/data/backups`, named by its time (`inbox-20261020T033012Z.db`), readable by its owner only. The inbox writes it while it keeps serving (`VACUUM INTO`), checks it, and only then gives it its name. `sudo inbox update` makes one before every update, and `sudo inbox restore` one before every restore. To copy them off the server, any tool that copies files will do. For example, every night after the backup, with [rclone](https://rclone.org) set up for your storage:
```bash
# /etc/cron.d/inbox-offsite (or a systemd timer): the backups to your own storage, at 04:30
30 4 * * * root rclone copy /opt/inbox/data/backups yourstorage:inbox-backups --max-age 48h
```
### Restoring [Section titled “Restoring”](#restoring)
```bash
sudo inbox restore /opt/inbox/data/backups/inbox-20261020T033012Z.db
```
It looks at the file first (that it is an inbox’s database, sound, and from this version or an older one), says what it holds, and asks. Then it makes a backup of the inbox as it is now, copies the backup into place, stops the inbox only for the swap, and starts it. If the inbox does not start on it, it puts back what was there. The last five backups made before a restore are kept. A backup from an older version is brought up to date when the inbox starts; one from a newer version is refused until you update. On a new server: run the installer with the same address, then put your `INBOX_SECRET_KEY` in `/opt/inbox/.env` in place of the one it generated (`sudo nano /opt/inbox/.env`), `sudo inbox restart`, and restore. ### With Docker or Compose of your own [Section titled “With Docker or Compose of your own”](#with-docker-or-compose-of-your-own)
```bash
docker exec inbox node /opt/inbox/server.mjs backup /opt/inbox/data/backups/inbox-$(date -u +%Y%m%dT%H%M%SZ).db
docker exec inbox node /opt/inbox/server.mjs check-db /opt/inbox/data/backups/ # before a restore
```
The backup command makes the `backups` folder when it is not there yet. To restore by hand: stop the container, replace `inbox.db` in the data folder with the backup (and delete `inbox.db-wal` and `inbox.db-shm` beside it), give it to uid 10001, and start the container. ## Litestream [Section titled “Litestream”](#litestream) [Litestream](https://litestream.io) copies the database to S3-compatible storage within seconds of every change, so a lost server loses at most those seconds. It runs beside the inbox, as a second line next to the nightly backups. On a server the installer set up:
```bash
cd /opt/inbox
sudo curl -fsSLO https://raw.githubusercontent.com/surfingdogai/inbox/main/deploy/litestream/docker-compose.litestream.yml
sudo curl -fsSLO https://raw.githubusercontent.com/surfingdogai/inbox/main/deploy/litestream/litestream.yml
sudo curl -fsSL https://raw.githubusercontent.com/surfingdogai/inbox/main/deploy/litestream/litestream.env.example -o litestream.env
sudo chmod 600 litestream.env && sudo nano litestream.env # the bucket, its endpoint, and a key for that bucket only
sudo inbox start
```
Its keys stay in `litestream.env`, apart from the inbox’s own settings. With the file there, the `inbox` command runs Litestream beside the inbox from then on, and stops it while a restore, a rollback or an update swaps the database. To restore from it, into a file the inbox then restores from:
```bash
sudo docker compose -f docker-compose.yml -f docker-compose.litestream.yml run --rm litestream \
restore -o /opt/inbox/data/from-litestream.db /opt/inbox/data/inbox.db
sudo inbox restore /opt/inbox/data/from-litestream.db
```
## On a platform [Section titled “On a platform”](#on-a-platform) Fly.io and Render snapshot their volumes and disks daily, and DigitalOcean can back up a whole Droplet; the [platforms page](/docs/platforms/) says how each keeps its data. The inbox’s own `backup` command works on each of them, and Litestream does too. ## On Cloudflare [Section titled “On Cloudflare”](#on-cloudflare) On Workers the database is D1, and D1 keeps its own history: [Time Travel](https://developers.cloudflare.com/d1/reference/time-travel/) restores it to any minute of the last 30 days (7 on the free plan).
```bash
npx wrangler d1 time-travel info surfingdog-inbox
npx wrangler d1 time-travel restore surfingdog-inbox --timestamp=2026-10-20T03:30:00Z
npx wrangler d1 export surfingdog-inbox --remote --output=inbox.sql # a copy to keep elsewhere
```
## Taking your data with you [Section titled “Taking your data with you”](#taking-your-data-with-you) Apart from backups, the owner app downloads everything the inbox holds, without sign-ins or secrets, ready to bring into another inbox.
# Calendars
> Connect iCloud, Fastmail, Nextcloud, any CalDAV calendar, a calendar's private link, Google Calendar or Cal.com. Busy times take those times off what your Inbox offers; the bookings it confirms go into your calendar.
Your Inbox offers times from the opening hours you set. A calendar you connect makes those times honest: when it says you are busy, agents are not offered that time, and every booking your Inbox confirms is written into it, moved when the booking moves and taken off when it is cancelled. Settings → Integrations → **Calendar** (iCloud, Fastmail, Nextcloud, any other that speaks CalDAV, a calendar link, or Google), **Cal.com** or [**Calendly**](/docs/connectors/calendly/). iCloud, Fastmail, Nextcloud and the other CalDAV calendars, a Google or Outlook calendar link, Google Calendar and Cal.com are built from each one’s documentation and tested against a stand-in; not yet verified on a live account. The Other way, which the other CalDAV calendars connect by, has since run end to end against a real CalDAV server, as [below](#icloud-fastmail-nextcloud-and-other-caldav-calendars) says. ## What it does, and what it never does [Section titled “What it does, and what it never does”](#what-it-does-and-what-it-never-does) * **Busy times only remove times.** A busy time in your calendar takes that time off what your Inbox offers and refuses to confirm a booking over it. It never moves, cancels or refuses a booking that was already confirmed. * **It takes the whole time.** For a service that takes several people at once, a busy time blocks all of its places: it means *you* are busy. * **All services, or the ones you choose.** Settings → Integrations → **Change**. * **About three months ahead.** Busy times are read 92 days ahead. Past that, no time is offered while a calendar is connected: a time nobody knows is free is not offered as free. A calendar with more ahead than is read says so on its connection, and the times after it stay closed. * **Your own bookings never block themselves.** The events your Inbox writes are recognised as its own and left out of busy times, whichever connection wrote them. * **Free events stay free.** An event marked *free* in your calendar app does not block anything, and neither does a cancelled one. An all-day event blocks the day only when it is marked *busy*: birthdays and holidays sit on whole days and are rarely meant to close a business. * **Test bookings never go out.** Bookings made in test mode stay in your Inbox. * **Disconnecting stops the busy times at once.** Bookings already written stay in your calendar: they are your entries now. Busy times are read every five minutes, and **Sync now** reads them at once. A booking goes into the calendar within seconds of being confirmed. When a connection stops working (an app password withdrawn, a key that expired), it says **Failing** with the reason, you get one email, and the inbox shows it beside Settings. **Connect again** asks only for the new password or key; everything you chose stays. Until then its last busy times keep blocking, and new events in that calendar do not. A sign-in refused for a day is not tried again until you connect again or press **Sync now**. ## iCloud, Fastmail, Nextcloud and other CalDAV calendars [Section titled “iCloud, Fastmail, Nextcloud and other CalDAV calendars”](#icloud-fastmail-nextcloud-and-other-caldav-calendars) You give an email and an **app password**, never your usual password: | | Where to make the app password | | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **iCloud** | [account.apple.com](https://account.apple.com/account/manage/section/security) → Sign-In and Security → App-Specific Passwords. Apple shows it once: copy it. Your Apple Account needs two-factor authentication. | | **Fastmail** | Settings → Privacy & Security → Manage app passwords and access → New app password, with calendar access ([how](https://www.fastmail.help/hc/en-us/articles/360058752854)). | | **Nextcloud** | Settings → Security → Devices & sessions → Create new app password. Give your Nextcloud’s address too. | | **Other** | Your provider’s settings. The server is found from your email’s domain (its DNS `_caldavs._tcp` record, then `/.well-known/caldav`); if it is not, add its address under **More options**. | Every calendar for events in the account blocks busy times until you choose otherwise, and bookings go to the first one you can write to. **Change** changes both. A calendar you add to the account later is listed under **Change** within about six hours, and blocks busy times once you tick it there. Gmail and Outlook addresses are told at once where they connect instead: their calendars do not open with an app password. **Other CalDAV calendars.** Tested on 1 November 2026 against a real CalDAV server (Radicale 3.9, run on our own machine), connected the Other way with an app password and the server’s address under More options: busy times from two calendars, with repeating events, Europe/Lisbon and America/New\_York times and all-day events; bookings written, moved and removed; a read-only calendar refused for bookings; a changed password marking the connection failing, its last busy times still blocking, then connected again in place. Not yet with iCloud’s, Fastmail’s or Nextcloud’s own servers, nor with a server found from the email alone. ## A calendar link [Section titled “A calendar link”](#a-calendar-link) **Calendar link** takes the private link your calendar gives for subscribing to it, the one that ends in `.ics` (Google: Settings → your calendar → *Secret address in iCal format*; Outlook: Settings → Calendar → Shared calendars → *Publish a calendar*). It blocks your busy times and adds nothing to the calendar. The link is a secret: anyone who has it can read the calendar, so it is kept like a password and never shown again. Use it for a calendar that cannot connect here yet. **Outlook** is this way, with Outlook’s own steps shown as you connect. ## Cal.com [Section titled “Cal.com”](#calcom) Paste an API key from Cal.com: **Settings → Developer → API keys → New**, with *Never expires* on. It is checked against Cal.com before anything is kept. * **Busy times** are your Cal.com bookings, and the calendars Cal.com checks for conflicts. * **Bookings** are created on the event type as long as the booking, or the one you choose. The customer is named, but Cal.com is given no email for them, so Cal.com sends them nothing: your Inbox has already told them. A booking that moves is rescheduled in Cal.com; one that is cancelled is cancelled there. * A time Cal.com will not take is said in Settings, on the connection, in Cal.com’s own words. * Cal.com adds the bookings it is given to the calendar it checks. If you connect that calendar here too, Settings offers to stop one of them adding bookings, so each shows once. * Cal.com cannot change the name on a booking it has: when a customer is erased, their name stays on the Cal.com bookings already made. Calendars connected directly lose it on the next sync. For a Cal.com you run yourself, give its API address under **More options**. ## Share your bookings [Section titled “Share your bookings”](#share-your-bookings) The other way round: **Share your bookings** makes a private calendar link to your confirmed bookings, for a booking site’s calendar sync, Google Calendar or a phone. It shows when you are booked and for which service, never who booked. Your Inbox knows its own bookings when a calendar copies them back, so they block nothing twice. See [Share your bookings](/docs/connectors/calendar-link/). ## Google Calendar [Section titled “Google Calendar”](#google-calendar) **Continue with Google** signs you in on Google’s own page. Google asks to let the Inbox see when you are busy (`calendar.freebusy`, times only, never what your events are) and to manage events on calendars you own (`calendar.events.owned`), which it uses only to add, move and remove the bookings it confirms; and who you are (`openid email`), so the connection is known by your Google account and not by a calendar’s name. It reads and writes your main calendar. Google needs an OAuth client. On a hosted inbox, Google shows as **Soon** until our Google client is approved; then it works with nothing to set up. On your own server, create one in Google Cloud (an OAuth client of type *Web application*, with `https:///oauth/connect/google_calendar/callback` as its redirect URI and the Google Calendar API switched on) and set `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`; until then Google shows as **set up**. For a business’s own Google Workspace, an *Internal* app needs no verification by Google. Until Google is connected, a [calendar link](#a-calendar-link) blocks your Google calendar’s busy times. ## Secrets [Section titled “Secrets”](#secrets) App passwords, API keys, calendar links and Google’s tokens are sealed with your `INBOX_SECRET_KEY` (AES-256-GCM) before they are stored, and no answer of any door ever carries them back, to you or to your AI. Without `INBOX_SECRET_KEY` nothing can be connected. Every call to a calendar goes over https to a public address, with the same checks as [webhooks](/docs/webhooks/): no private or local address, redirects followed by hand and checked again, and a limit on time and size. An app password goes only to the calendar server that asks for it: every address found on the way is asked first without it, a redirect to another host goes without it (iCloud’s own hosts excepted), and a calendar a server names on another host is refused. ## Over the API [Section titled “Over the API”](#over-the-api)
```bash
# What can be connected, and what is
curl https:///v1/owner/integrations -H "authorization: Bearer sdi_own_…"
# Connect iCloud
curl -X POST https:///v1/owner/integrations \
-H "authorization: Bearer sdi_own_…" -H 'content-type: application/json' \
-d '{ "kind": "caldav", "preset": "icloud", "values": { "username": "you@icloud.com", "password": "abcd-efgh-ijkl-mnop" } }'
# Connect it again with a new app password, keeping every choice
curl -X POST https:///v1/owner/integrations \
-H "authorization: Bearer sdi_own_…" -H 'content-type: application/json' \
-d '{ "kind": "caldav", "connection_id": "", "values": { "password": "qrst-uvwx-yzab-cdef" } }'
```
`PATCH /v1/owner/integrations/{id}` chooses calendars (`busy_calendars`), where bookings go (`destination`), which services busy times apply to (`services`, `null` for all) and pauses (`paused`); `POST …/{id}/sync` syncs now; `DELETE …/{id}` disconnects. `POST /v1/owner/integrations/authorize` with `{"kind": "google_calendar"}` answers the address to open for Google’s sign-in. Connecting, changing and disconnecting are yours, in person: your AI and an integration key are refused (`403`). Your AI, over the owner MCP, can `list_integrations` and `sync_integration`, and an integration key with `integrations:write` can list and sync. ## Writing a connector [Section titled “Writing a connector”](#writing-a-connector) A connector is a small object in `packages/adapters/src/connectors`: what it does for the Inbox (`busy_times`, `write_bookings`, `pay_links`), a Zod schema for what the owner gives it with its secret fields marked (`secretField`), and the handlers that sync. The Settings form is drawn from the schema; sealing, the HTTP client’s checks, claims that stop two jobs writing one booking twice, the five-minute schedule and its back-off, and telling the owner when it fails are the Inbox’s. See `packages/core/src/connectors/sdk.ts`.
# Concepts
> Typed items and their states, rules, agent policy and trust tiers, networks, the people they recognise, and receipts.
## A typed inbox [Section titled “A typed inbox”](#a-typed-inbox) Everything that arrives becomes an **item** of one of five types: `message`, `quote_request`, `booking`, `order` or `refund`. An item has a typed payload (field names follow schema.org where they fit: `reservationFor`, `orderedItem`, `totalPrice`), a state, a version number, the party it belongs to, the channel it came through, and three flags: `needsHuman`, `priority` (0 to 3) and `sandbox`. Each type has a state machine, defined as data. A transition names the event, who may fire it (customers, the owner and staff, the owner’s AI, a rule, a connector, the system), the states it applies from, the state it leads to, optional guards, and the effects it triggers. | Type | States | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | booking | requested, needs\_info, proposed, confirmed, completed, no\_show, cancelled\_by\_customer, cancelled\_by\_business, declined, expired | | order | received, needs\_info, proposed, accepted, awaiting\_payment, payment\_failed, paid, fulfilling, fulfilled, completed, declined, cancelled, charged\_back, expired | | quote\_request | received, needs\_info, quoted, accepted, declined, expired | | message | open, answered, closed, spam | | refund | requested, approved, goods\_received, refunded, rejected, cancelled | A booking, for example, moves from `requested` to `confirmed` when the owner fires `confirm` (guard: the slot is still free; effects: claim the slot, issue a receipt, notify the customer), or to `proposed` when the owner fires `propose` with another time. The customer then answers, through their assistant or the links in the business’s email: `accept` books that time, `cancel` declines it and closes the request, and `counter` asks for another free time, which puts the request back with the owner. When the customer said yes by phone or in person, the owner’s `confirm` on a proposed booking books the proposed time; only a person may do that, not the owner’s AI, a rule or an integration key. The owner can also `confirm` a booking that was waiting on the customer’s details (`needs_info`), and a customer’s details, however they send them, move it back to `requested`; an automatic reply from their mailbox (an out-of-office, a returned email) is kept on the item as a note and moves nothing, and one that answers no item is kept as a notice, for the owner alone: none is ever answered. Nothing is booked, proposed or accepted for a time that has already started, and free times are offered only from the business’s **minimum notice** on (an hour unless the owner sets another, in Settings → General): inside it, a customer’s request is taken and waits for a person (a rule that would confirm it flags it for one instead), and no customer, rule, AI or key books the time, while the owner or staff in the app still may; nobody proposes or quotes a time inside it, since the customer could not accept it in time. Whoever reads an item is told which events it accepts right now, so the valid next actions are never a guess: for the owner they become the primary buttons, for an agent they are listed in the item view. A quote works the same way: the owner’s `quote` (again, to replace one already sent) must add up, and a quote that creates a booking names the time. When the customer accepts it, what it was for is created in the same write, already promised: the booking confirmed at the quoted time, or the order accepted. An order can be answered the same way too: the owner’s `propose` suggests changes (another quantity, a price of their own, a delivery date), which the customer accepts, declines (closing the order) or answers with quantities or a date of their own. When a customer asks the business, by email or phone, to cancel, the owner records it with `record_cancel` and the customer’s words: it is the customer’s cancellation, judged late or not at the moment they asked, never the business’s. **Offers.** Everything the two sides put to each other before a promise is an **offer**: the customer’s request is the first, and each time the business proposes, quotes or suggests changes, or the customer answers with a time, quantities or a date of their own (`make_offer`), the open one is closed (`countered`, or `superseded` when its own side replaces it) and the next opens, one round further. Each is kept as an immutable snapshot of its terms with their fingerprint (`terms_sha`), who made it, until when it holds, what changed from the one before, and what the other side was shown; accepting one (`accepted`) is the promise, and the item’s payload points at it (`offer`). There is at most one open offer per item, and every verb happens once, in the same write as the item’s move. The business sets its prices: a price of the customer’s own goes to a person at the business as their message, never refused, and so does a suggestion after `negotiation.maxRounds` rounds. The owner’s AI and rules change times, quantities and delivery, never a price, and propose nothing more after the last round. With `negotiation.binding` off, what the business proposes says it is subject to its confirmation, and `retract` withdraws it before the customer answers; the customer’s request comes back to the owner. The owner sees every offer of an item (`list_offers`). **Changes to what was agreed.** A confirmed booking or an accepted order can change without being cancelled. Either side asks (`propose_change`: the business names another time, a price of the owner’s, other lines or a delivery date; the customer another time, other quantities or a delivery date, never a price), and the other side answers: `accept_change` moves the promise in the same write — its time and its places, or its lines and total — while `decline_change`, the asker’s `retract_change` and a lapse (`expire_change`) leave it exactly as agreed. The customer hears it in the business’s words (“Can we move your booking from Tue 10:00 to Wed 10:00? If that doesn’t suit you, your booking stays as it is.”), with links to accept or to keep it as it is, and every confirmation carries a link to ask for another time. A customer’s time must be one the business would offer (its own places count as free to it), and a change the business asks for is held for them like a proposed time. A change lapses at its date: one the customer asked for after `negotiation.counterValidHours`, and before either start; one the business asked for at the date it gives, else after `negotiation.offerValidHours` for an order, and for a booking never after either start less the minimum notice; one the owner’s AI or a rule asked for after `negotiation.offerValidHours` at the latest. Each promise takes at most `negotiation.changes.maxPerItem` changes (three at most), none moving its date, or a booking’s end, more than 90 days from what was first agreed; past that, or past the last round, a customer’s change goes to a person as their message, never refused. The owner’s AI and rules accept a customer’s change only when `negotiation.ai.mayAcceptChanges` allows it, before `negotiation.changes.customerCutoffMin` (else the cancellation window) and at the business’s prices, and ask for one only when `negotiation.ai.mayProposeChanges` does, never at another price. Once payment was asked for or made, a change keeps the order’s total. A change both sides agreed is a signed receipt of its own (an **amendment**), which a network reads from its rules version 6 on: while a network that holds the promise, or is switched on to be sent it, does not yet have version 6 in force, it would hold the business to the date first agreed, so that promise does not change: the customer’s request goes to a person, and the owner hears the way to do it (the customer’s own cancellation and a new booking). With no network on, a change is recorded at once. **The confirm step.** A booking or an order that carries a price binds a consumer only once they confirmed its summary — what, when, from whom, the total, the right to withdraw or why there is none (where no right of withdrawal is claimed, that what was bought cannot be returned, or else a return policy the business wrote, if it wrote one), and that confirming means paying — as the law asks before an order to pay. Their assistant is shown the summary first (`409 confirm_terms`, or on MCP a result that says “Nothing is sent yet”), and sends the request again with its fingerprint on their yes; nothing is written before. A business that sells only to businesses (`commerce.customers`) is not asked for it, nor is a request that holds no price of the business’s yet, or a free one. **Returns and withdrawal.** The business’s own country decides the consumer law the inbox applies, never the buyer’s; a buyer’s own law may give more, which the business honours itself. Where the business’s country is in the EU, the EEA, Gibraltar or the UK, or none is set, a customer may withdraw from a contract made at a distance within 14 days (or the business’s longer `returns.days`), without a reason: from an order, from its acceptance until 14 days after the goods reached them, and from a booking they paid for, until it starts. The period is counted as the law counts it — the first day not counted, an end on a weekend or a public holiday moved to the next working day, in the business’s time zone — and nothing a product or service is excepted for (made to order, perishable, for a set date, …; its `withdrawal` flag, the owner’s to set) may be withdrawn from; an excepted line of an order keeps no other line from its right, and each is told its own. Every confirmation of such a contract carries the right and a **Withdraw from contract here** link, which shows the statement and sends it on **Confirm withdrawal**, and the first also the model form and who the business is; the customer’s assistant does the same in two steps (`withdraw_from_contract`), and a customer’s cancel of something they paid for is their withdrawal while the right runs. Before the goods went out a withdrawal ends the order or the booking, neutrally, and what was paid is refunded within 14 days; after, the goods come back and the refund follows them. It is never refused: past the period it becomes a return under the business’s own policy, or the customer’s message for a person. Elsewhere no right of withdrawal is claimed: the customer is told, before an order binds and in its first confirmation, which lines cannot be returned and what the return policies the owner wrote give the others, and a change of mind is a return under that, which the business answers. A contract keeps the law it was made under (the one its customer was told before they confirmed, unless that claimed no right and the business’s country is in the EU, the EEA, Gibraltar or the UK when it becomes a contract), whatever the business’s country becomes after. A **return** is an item of type `refund`, linked to the order and the same customer’s: a withdrawal, faulty goods under the legal guarantee, a return under the business’s policy, or a paid order the business cancelled. The business approves it (with the goods coming back by a date, or not), marks the goods back, and refunds; only a person refuses one (never a withdrawal), disputes what came back, or cancels a paid order, and a refund of less than what is owed needs the customer’s agreement. The owner’s AI and rules may approve a return inside the owner’s policy with the goods coming back (`negotiation.ai.mayAuthorizeReturnsInPolicy`; where no right of withdrawal is claimed, only within what the customer was told of returning those lines as the order was made, kept on the order), never pay one. A refund is due within 14 days of a withdrawal’s notice (or three days after the goods come back, when later), else `returns.refundDays` after nothing more has to come back; the owner is told before it falls due. Returning something never counts in what the business knows of a customer, and a charge-back while a return is open counts against nobody. A refund of a customer’s order is a promise of the business’s own, with its own receipts: kept when paid by its date, broken when paid after it. **Nothing waits forever.** A request nobody answered lapses (`expired`): a booking after `booking.autoExpireHours` and at its start at the latest, an order or a quote request after `negotiation.counterValidHours`, counted from when it came in or the customer last answered or was asked something. What the business proposed lapses at its deadline: a time at its start less the minimum notice, a quote at the date it gives, changes to an order at theirs, anything without a date after `negotiation.offerValidHours`, and whatever the owner’s AI or a rule proposed after that at the latest. The deadline is checked in the write that would accept it, and the sweep closes what lapsed and tells the customer, in the business’s words. With `booking.holdOnPropose` on, a time the business proposes is held for the customer until they answer, at most `booking.maxHolds` at once per customer. **What the customer hears.** Every email to a customer is the business speaking, in its name and in the customer’s language (English or Portuguese: the language their assistant gave, else the business’s first language, which the setup asks for). A booking, order or quote request is acknowledged a moment after it arrives, unless the business, or a rule, answers first; then every change the customer needs to know of gets an email with the time in the business’s time zone and the zone named, the price and the total, until when to answer, the links to answer with, and a six-character reference, never an id. A reply to any of them lands on the same item. An email no person typed — a rule’s, the owner’s AI’s, another system’s (an integration key, unless its request says a person wrote it) — says so in one line, “This reply was sent automatically. Reply to reach a person.”, in the business’s words. Each email is kept with what became of it: the owner sees on the item whether it was sent, is still being tried, failed, or was not sent and why, and an email to the customer that failed for good marks the item as needing a person. A customer who came through an assistant reads the business’s replies at the status door. A confirmed booking and an accepted order are **promises**, and every way out of one records how it ended as a signed receipt: completed, a no-show, cancelled by either side (a withdrawal is the customer’s own, never late), fulfilled, not fulfilled, a failed payment, a charge-back. A change both sides agreed to one is a receipt too, and the outcome then names the new dates; a refund owed is a promise of its own, paid on time or late. Some of it happens because time passed: a confirmed booking counts as completed 48 hours after it ended unless it was marked a no-show (`booking.autoCompleteHours`), and an order whose payment never came lapses 14 days after it was requested (`orders.payDays`), which closes it for the networks and leaves it open for you. A customer who cancels after your cancellation window is recorded as cancelling late, or refused, as `booking.lateCancellation` says. [Receipts](/docs/receipts/) has the full list. ## Events, versions, idempotency [Section titled “Events, versions, idempotency”](#events-versions-idempotency) Every transition is an event with an actor, a timestamp, a reason and a diff, appended to the item’s history. The event sequence number doubles as the version: a write that carries `expected_version` fails with `version_conflict` if someone else moved the item first. Every mutation from an agent or an API client carries an **idempotency key**. The same key with the same request returns the same answer and creates nothing new; the same key with a different request is refused with `idempotency_mismatch`. Correctness lives in the database: unique constraints, idempotency rows and slot claims are written in one batch, so races are settled by SQLite rather than by application code. ## Rules [Section titled “Rules”](#rules) Rules are plain JSON, evaluated deterministically after each committed event. A rule has triggers (`item.created`, `item.transitioned`, `item.transitioned:`, `thread.inbound`), a condition and actions. Conditions combine `all`, `any` and `not` with comparisons on paths in the item (`eq`, `neq`, `lt`, `lte`, `gt`, `gte`, `in`, `nin`, `contains`, `startsWith`, `exists`, `empty`, `between`) and a few named checks: `slot_is_free`, `within_business_hours`, `party_verified`, `text_has_keywords`, `is_sandbox`. Actions are `transition`, `set_flags`, `reply` (to the customer, or an internal note), `enqueue` a job, or `stop`. A rule can also read who is asking: `person` (the best `tier` and `score` across the networks that presented the customer, each network’s own, and `limit_minor`, 40000 for a trusted person), `customer` (`match` — `strong`, `weak` or `none` — and your own history with them: `completed`, `paid`, `no_shows`, `late_cancellations`, `payment_failed`, `charged_back`, `largest_paid`, `open_bookings`, `first_seen`) and `agent` (`level`: `vouched`, `self` or `none`, and `platform`), with the checks `person_trusted`, `person_tier_on {network, min}`, `customer_known` (a customer you know with a completed visit or order) and `within_customer_limit` (within twice their largest paid order, or a trusted person’s limit). **Positive only**: a rule that reads any of these, or `party_verified` or the trust tier, may speed things up or ask a person — confirm, accept, ask for payment, flag, reply — and never decline, cancel, expire, mark a no-show or queue a job. Saving one that would is refused with `positive_only`; an older one has those actions skipped, and the rule’s run, the item’s timeline and “Try it” say so in plain words. `agent.level` is `vouched` only for a platform a network you report to recognises; any other signed agent is `self`. Nothing in a condition does I/O; everything a rule may read is fetched into a frozen context first. Limits keep rules honest: at most 20 rules per event, a chain depth of 3, and a per-rule cap on runs per item. **A rule reads your price, and promises only on it.** A booking’s or an order’s `totalPrice` is the one your catalogue gives; a different figure the customer’s request carried is kept as `customerStatedPrice`, which no condition can read. A request that holds a price your catalogue does not give (an order line naming no product you sell, a booking of a service priced `from`, by `quote` or not at all that names a price, or a fixed-price service booked for longer than it lasts) is never confirmed or accepted by a rule: a rule that tries flags it for a person instead, and the rule’s run and “Try it” say so. **Your AI does time, and money only within your limits.** The AI you connect to the owner’s MCP is held the same way, and further: it never confirms or accepts a request that holds a price you did not set, never records a payment or a refund, never changes a price or your currency, and never connects or removes a feed (a feed sets prices). What it adds with a price is saved unpublished for you to check and publish. What it offers — a time, a quote, changes to an order — is judged against your limits (below), and so is what a rule or another system’s key without `money:write` offers. When money is involved it leaves you a note with what it suggests. **Your limits, in code.** What automation may agree to on its own is yours to set (`negotiation.ai`), and it is enforced in the write, not asked of a prompt: out of the box, time yes, money no — any free time within seven days of the one asked for, no discount, no price the catalogue does not give, no later delivery, no change asked of the customer, no refund with nothing sent back. Inside them an offer goes to the customer as it would from you. Outside them it is not sent: it is kept as the item’s **draft** for you to send as it is, change or drop, with the limits it is outside named as codes; an acceptance outside them is refused, and a reply naming an amount of money you have not offered is kept as a note. Automation learns a limit as a code when it meets it, never as a number, and never reads your lowest prices (floors, per product or service), your limits or your rewards, so no customer’s message can get it to recite one. You in person are never held. **Prices of the customer’s own.** Out of the box you set your prices: a price the customer suggests reaches a person as their message, and what you proposed stands. Switched on (`negotiation.priceCounters`), it is their answer to your offer, for you to take or answer; automation takes one only at or above your floor, and never answers a price with another, since a floor revealed one step at a time is a floor given away. What you mark not to be haggled, a quote, and a customer who suggests prices too often go to a person, never refused. **Rewards.** A good record may earn a better price, set by you and applied by the inbox: a condition on what the customer earned — your own history with them, the standing their assistant showed — and a percentage off the list price, never below your floor and never above the list price, the best one that matches and never several. A reward never reads who or where someone is. Whenever a price was chosen for the customer by a reward or by automation, everything that shows it to them says so, beside the list price. **Prices per person.** A service’s fixed price is for the whole booking unless you set it per person, in which case a booking for four costs four times the price. Presets exist for three verticals. Appointments: confirm at once a customer you know (two completed, no no-show) or a trusted one (at most two open bookings, up to 200.00), otherwise auto-confirm bookings under a limit when the slot is free and inside opening hours, and ask a person about everything else. Trades: quotes always need a person, urgent words raise priority, trusted customers and customers you know go first. Shop: accept at once an order within a customer’s limit, accept small orders and ask a new customer to pay, flag large ones for approval. All three offer a one-time code to someone who gives a known customer’s address and asks about earlier items. A new customer’s booking is never refused for being new: at a time you offer, it waits for a person. ## Agent policy and trust tiers [Section titled “Agent policy and trust tiers”](#agent-policy-and-trust-tiers) Every request is resolved into a **caller** with an actor, a channel and a trust tier. The tiers are: * `anonymous`: nobody proved anything. Allowed to send messages and to request quotes, bookings and orders. On creation the caller receives an `access_token`, a capability secret that is the only way to read or cancel that item later. * `signed_agent`: the request carried a verified HTTP Message Signature (`sdi-agent/1`: the agent’s own Ed25519 key, or a platform’s key under Web Bot Auth). A signature that does not verify counts as none, and the answer says why in `Sdi-Signature`. * `verified_principal`: the caller holds an API key issued by the instance, an OAuth token, or a session; email that arrived through a path that verified DKIM and SPF is treated the same way. * `reputed_principal`: an agent a platform vouches for, carrying a person a network trusts. **People and customers you already know.** An agent may carry its person’s pass (`sdpass1_…`), issued by a network; the inbox presents it to that network, which says how that person keeps their promises, and a person the business has met before is recognised as the same customer. A first booking or order with an email and nothing carried gets the customer a key from each network switched on, and the agent their first pass; the key reaches the customer a day later in a short email of its own, with one line about the booking network and a link to a page on the instance that explains it, where the customer can switch it off for themselves, and never rides on another email. From then on no network hears anything more about them, and the owner sees that they did. Someone who only gives the address of a customer you know is a **weak** match: their item stays on its own, you see “may be Ana Silva, unconfirmed”, and a one-time code sent to the known address makes them the same customer. The agent’s side of all this is [the agent guide](https://surfingdog.ai/for-agents.md). The manifest publishes which tiers an instance serves. A tier or a record only ever speeds things up: nobody is refused for being unknown or unsigned, and a new customer’s booking at a time you offer waits for a person’s confirmation. **Test mode**. An instance in test mode marks every item as `sandbox`. Any caller can also ask for a sandbox item with the `x-sandbox: 1` header or by calling a `sandbox.` hostname. Sandbox items go through the same machines, but nothing leaves the inbox: no email to the customer or to you (each is kept as not sent, so you can read what would have gone), nothing asked of or shown to a network, no receipt. While test mode is on this holds for every request, a real customer’s too, and the inbox says so above the list. ## Networks [Section titled “Networks”](#networks) An instance can report to several networks, and each one lists it in its own directory. They are the `networks` setting, a map keyed by each network’s https origin: `{"networks": {"https://network.surfingdog.ai": {"enabled": true}}}`. Settings are merged, never replaced, so adding one network leaves the others as they are, and switching one off is `"enabled": false` (it stays in the list, and anything it missed is sent when it is switched on again). At most eight. Each entry can limit what that network gets, with `share: {"counts": false}` or `share: {"receipts": false}`, and whether it gives first-time customers a key, with `issue: false`; everything is on by default. A fresh instance lists `https://network.surfingdog.ai`, switched off. Any service that implements the [network protocol](https://github.com/surfingdogai/inbox/blob/main/docs/protocol/network.md) works. Its rules say what it offers: at the **directory** level it lists the business and keeps its receipts, with no standing to show and no keys to give, so the instance never asks it for either and Settings → Networks says so; at the **full** level it does both. To run one, start from the [example network](https://github.com/surfingdogai/inbox/tree/main/examples/network) and check it with [network-check](https://github.com/surfingdogai/inbox/tree/main/packages/network-check). For each network that is on, the instance registers its domain, which the network verifies by fetching the manifest (until it has, the instance asks again at most once a day); then every hour it sends its software version, its runtime and the number of bookings, orders, quotes and messages created in the last 24 hours, and it publishes every receipt it issues (below), which names the customer only by a pseudonym. With `INBOX_SECRET_KEY` set the hourly ping is signed, and the network answers it with the business’s own **standing** there — its tier and score — which Settings → Networks shows. Each network is called on its own, so one that is slow or down never holds up another. An older document’s `network: {url, join}` is read as one entry of this map. The numbers on the front page of surfingdog.ai are the sum of the pings `network.surfingdog.ai` receives. A network also keeps a record for **people**. A customer’s assistant presents their pass; the network that issued it answers with how that person keeps their bookings and orders (`new`, `building` or `trusted`, what they kept and broke, since when), and the owner sees it on the item, one line per network, only for people whose assistant presented a pass. A customer’s first booking or order with an email, from an assistant carrying nothing, gets them a key from each network with `issue` on: the instance sends that network the email address, and the network keeps a keyed hash of it. The network emails the key to the customer itself, in the business’s name, once the business’s receipt names them, so the business never holds it; a network that leaves that email to the business sends the key to the instance, which emails it a day later, alone, with a link to the page that names the network, says what it keeps, and stops it for them with one button: after that, nothing more about them reaches any network, and what a network already has is listed for the owner (a network cannot yet be asked to erase it). The [privacy page](https://surfingdog.ai/privacy) lists everything that crosses. ## Receipts [Section titled “Receipts”](#receipts) When a booking or an order becomes a promise (a booking confirmed, an order accepted or paid) the instance issues a **receipt**: a compact JWS signed with the instance’s Ed25519 key, which the manifest publishes in `receipt_keys` and `/.well-known/jwks.json` serves as a plain JWKS. When the promise closes it issues another, the **outcome**: completed, a no-show, cancelled by either side, fulfilled, and so on. A change both sides agreed in between is an **amendment**, and a refund has a promise and an outcome of its own; networks read these from their rules version 6 on, announced fifteen days before it counts (at once while a network has no other member), and are sent them only then. The receipts are on the item for whoever may read it, and the customer’s agent counter-signs them with `acknowledge_receipt`. Both sides then hold a small, verifiable proof that this transaction happened between these two parties and how it ended, without either holding the other’s address: the receipt names the customer by a pseudonym only this instance can produce. A network counts a promise when it closes, kept or broken, for the business and for the customer, and weighs what a verified agent counter-signed above what the business says alone. The format and the rules are on the [Receipts](/docs/receipts/) page; how a network scores and orders is its published rules (`GET /v1/ranking` on the network; for ours, [the network page](https://surfingdog.ai/network)). What a network measures is promise against delivery, not taste: every promise is kept, broken or still open. Reviews of taste may come later, only ever against a co-signed receipt; [ADR-012](https://github.com/surfingdogai/inbox/blob/main/docs/adr/012-reputation-and-reviews-law.md) explains the legal reasoning.
# Connect your AI
> Let Claude, ChatGPT, Gemini, Grok, Grok Bot, Copilot Studio or any MCP client work your inbox on your behalf through the owner MCP.
Every instance serves two MCP servers. The public one at `/mcp` is for customers’ agents and needs no login. The **owner MCP** at `/mcp/owner` is for you and the AI you trust: it lists what needs a person, shows an item’s full story, moves items through their states, replies to customers and edits settings. It is stateless per request (MCP spec 2026-07-28), and its tools are generated from the same schemas as the REST API. Two ways in: an API key, or OAuth 2.1 with a login. ## Today: an owner API key [Section titled “Today: an owner API key”](#today-an-owner-api-key) An owner key is a bearer token that starts with `sdi_own_`. On your server, from the directory the server runs in or with `INBOX_DB` set, so the key goes into its database (`--env-file-if-exists` reads a `.env` there when you have one):
```bash
node --env-file-if-exists=.env apps/inbox/dist/server.mjs create-owner-key laptop
```
The key is printed once and stored hashed. It is the same key the owner app takes at `/login`. Send it as `Authorization: Bearer sdi_own_…` to `/mcp/owner` or to any `/v1/owner/*` route; for Claude Code, for example:
```bash
claude mcp add --transport http --header "Authorization: Bearer sdi_own_…" inbox https:///mcp/owner
```
Any MCP client that can send a header works the same way. Agent keys (`sdi_agent_…`) exist for customers’ agents that want a stable identity on the public doors. Without a command line, on a Cloudflare install for example, sign in to the owner app by email and create a key under **Settings → Keys**. Keys made there are integration keys: named, limited to the scopes you pick, and revocable in one click. Give each system its own; see [Keys and scopes](/docs/api/#keys-and-scopes). ## OAuth 2.1 [Section titled “OAuth 2.1”](#oauth-21) The instance is its own authorization server, so a consumer product connects with a login rather than a pasted key ([ADR-004](https://github.com/surfingdogai/inbox/blob/main/docs/adr/004-owner-auth.md)). A client discovers it from the protected resource:
```plaintext
GET /.well-known/oauth-protected-resource/mcp/owner
GET /.well-known/oauth-authorization-server
```
What it supports: authorization code with PKCE (S256, required); public clients only; **Client ID Metadata Documents** first, which means the `client_id` is the `https` URL of a JSON document describing the client, fetched by the instance and cached; dynamic client registration (`POST /oauth/register`) as the fallback for clients that still use it; opaque hashed tokens; access tokens that last an hour and refresh tokens that last thirty days and rotate on every use, with reuse detection; one consent screen listing the scopes in plain words. Anyone can register an app, because assistants register themselves when they connect, so the consent screen doesn’t take an app’s word for its name. It shows where the app’s access goes (the host its sign-in comes back to), marks a self-registered app’s name as not verified, and warns when an app calls itself Claude, ChatGPT, Gemini, Grok, Copilot or Muse but sends access somewhere else. Only that page can answer it: an Allow posted from another site is refused. Each time you allow an app the inbox emails you, and Settings → Keys lists every connected app with a Disconnect button that revokes its tokens at once. Self-registered apps nobody signed in with are forgotten after a week. Scopes: `inbox:read`, `inbox:write`, `events:read`, `catalogue:write`, `availability:write`, `settings:read`, `settings:write`, `setup:run`, `integrations:write`, `keys:write`, `offline_access`. A client that asks for none is offered every one of them but `keys:write` (keys are yours in person, whatever an app holds), and the consent screen lists each. A call outside what you allowed is refused, `403 not_allowed` with `details.reason: "outside_scopes"` and the scope it needed, and is listed under Settings → Keys; to give an app more, disconnect it there and connect it again. That is **Refuse calls outside a key’s scopes**, on out of the box; off, such a call goes through and is only listed. An app that asked for none before calls outside the scopes were refused was given only `inbox:read inbox:write settings:read offline_access`, and keeps that: Settings → Keys says so beside it, and connecting it again lets it set the business up. In the history and in every event, the AI appears as itself: actor `owner_ai` with the app’s name, not as you. Endpoints: `/oauth/authorize`, `/oauth/token`, `/oauth/register`, `/oauth/revoke`. The authorization step needs an owner session in the browser, which comes from a sign-in email (`POST /auth/magic-link`; then the page its link opens, whose button posts to `/auth/verify`, or its six-digit code posted to `/auth/code`; the first address to sign in on a fresh instance becomes its owner). The owner app offers both ways in: the sign-in email, which creates that session, and an owner API key for people who only ever use the API. **What has run.** On 1 November 2026, Gemini CLI 0.66 and the MCP Inspector 2.13 worked an inbox run on our own machine through the owner MCP, with a key and with a token from its OAuth sign-in, which a script of ours went through rather than either client. Claude Code 2.1.72 connected and found the sign-in; no Claude model ran yet. The steps below for Claude, ChatGPT, Gemini, Grok and Copilot Studio are their makers’ own, and none of them has run against an inbox yet. ### Claude [Section titled “Claude”](#claude) In Claude, go to Settings, then Connectors, and add a custom connector with your owner MCP URL, `https:///mcp/owner`. Claude fetches the metadata, sends you to your instance to sign in and approve the scopes, and connects. Claude uses a Client ID Metadata Document, so nothing needs to be registered by hand. ### ChatGPT [Section titled “ChatGPT”](#chatgpt) In ChatGPT, enable developer mode in the connector settings, then create a connector with the same URL and choose OAuth. Sign in on your instance when asked. ### Gemini [Section titled “Gemini”](#gemini) On a computer, open gemini.google.com, then Settings, then Connected Apps. Under Custom apps, choose Add a custom app, paste `https:///mcp/owner` and approve it in the browser; it then works in the Gemini mobile app too. On Gemini Enterprise, a team adds it under Manage team, Connected apps, Add MCP Server. ### Grok and Grok Bot [Section titled “Grok and Grok Bot”](#grok-and-grok-bot) In Grok, go to grok.com/connectors, choose New Connector, then Custom, and paste the owner MCP URL (custom connectors are on xAI’s paid plans). In Grok Bot, say “Add a custom MCP server called My shop at `https:///mcp/owner`” and complete the sign-in on the card it shows; its plugins then work in every bot you make. Both take OAuth, or an API key as a header: `Authorization` with the value `Bearer sdi_own_…`. ### Meta Muse [Section titled “Meta Muse”](#meta-muse) Muse can build a custom connector from what a service publishes: your instance’s `/openapi.json` and its public MCP server at `/mcp` are what it reads to book, quote and order for its person. Meta’s reviewed directory is a separate submission to Meta, in the US and Canada. ### Copilot Studio [Section titled “Copilot Studio”](#copilot-studio) In a Copilot Studio agent, add a tool, choose an existing Model Context Protocol server, and give it the owner MCP URL with OAuth (dynamic client registration) or an API key header. Microsoft 365 Copilot’s own federated connectors are read-only for now. ### Other clients [Section titled “Other clients”](#other-clients) Cursor, VS Code and any other client that speaks Streamable HTTP with OAuth 2.1 take the same sign-in; Cursor and VS Code have not run against an inbox yet. Clients that cannot do OAuth use an API key. Both MCP servers answer every protocol revision from 2025-03-26 to 2026-07-28. Step by step for each assistant: [Assistants](/assistants/). ## What the owner tools do [Section titled “What the owner tools do”](#what-the-owner-tools-do) There are 44 owner tools: | Tool | Does | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list_items` | Items newest first, filtered by type, state or `needs_human`, or searched with `q`. | | `get_item` | One item with its typed fields, event history, conversation and the valid next transitions. | | `transition_item` | Fire one of the events the item lists (`confirm`, `propose`, `decline`, `quote`, …); pass `expected_version` to avoid racing a colleague. A cancellation the customer asked for by email or phone is `record_cancel`, with their words as the note: it counts as theirs, not yours. A time you proposed is booked when the customer accepts it; your AI cannot record a yes they gave by phone, only a person can (`confirm` in the owner app). | | `open_return` | A customer asked you, by email or phone, to send back goods of a fulfilled order: write it down as a return (why, and what they said). A withdrawal they wrote, while a period to withdraw runs, is `transition_item` `record_withdrawal`, naming their message (`entryId`). Your AI may approve a return inside your policy, the goods coming back; refusing one, disputing what came back and refunding are yours. | | `make_offer` | Propose as the item’s state allows: another time for a booking, changes to an order (quantities, a delivery date), a quote, or a change to a confirmed booking or an accepted order, which stays as agreed if the customer says no. Your AI changes times, quantities and delivery, never a price or a quote, and after a few rounds of back and forth leaves the rest to you. It asks a customer for a change only if you let it (Settings → Changes to what was agreed); a customer’s own change it accepts with `transition_item` `accept_change`, or declines with `decline_change`. | | `list_offers` | Every offer on an item: the customer’s request, what you proposed, each answer, what was agreed, and until when each held. | | `reply` | Send a reply to the customer, or an internal note with `internal: true`. A reply your AI sends reaches the customer with one line in your words: “This reply was sent automatically. Reply to reach a person.” `get_item` shows whether each email went out. | | `export_customer` | Everything the inbox holds about one customer, as one document, for a customer who asks what you have about them. Erasing a customer is yours alone, in the app: your AI cannot. | | `stop_customer_networks` | For a customer who asks not to be known to booking networks: from then on nothing about them goes to any network. | | `get_profile`, `update_profile` | The business’s name, domain, time zone, currency and languages. The currency is yours to change. | | `list_services`, `upsert_service`, `archive_service` | Bookable services. A price is per booking unless it is per person. A service your AI adds with a price is saved unpublished for you to check; it cannot change a price or publish a priced service. | | `list_products`, `upsert_product`, `archive_product` | Products. A product your AI adds is saved unpublished for you to check; it cannot change a price or publish a product, nor how a price is meant, a “was” or unit price, an age, or a product’s shipping and return terms. | | `upsert_product_group` | A product with variants (sizes, colours, roasts): your AI may make the group and give each variant its options. | | `get_availability`, `set_opening_hours`, `clear_service_hours`, `set_closures` | Opening hours and closed days. | | `list_rules`, `list_rule_presets`, `apply_rule_preset`, `upsert_rule`, `delete_rule`, `test_rule` | What happens on its own. | | `list_webhooks`, `create_webhook`, `update_webhook`, `rotate_webhook_secret`, `delete_webhook`, `send_test_event`, `list_webhook_deliveries`, `replay_webhook_delivery`, `replay_missing_webhook_deliveries`, `list_events` | Where events go, and the event stream. Adding, changing, rotating or removing an endpoint is yours, in Settings → Integrations: your AI can list, test, replay and pause one. See [Webhooks](/docs/webhooks/). | | `list_feeds`, `add_feed`, `import_feed_now`, `remove_feed` | Product feeds. A feed sets prices, so only you connect or remove one: your AI reads them and imports one again. See [Feeds](/docs/feeds/). | | `list_integrations`, `sync_integration` | Connected calendars: what can be connected and what is, each one’s busy times, bookings written and last error, and a sync now. A connection holds your password or sign-in and decides which times customers are offered, so only you connect, change or disconnect one, in Settings → Integrations: your AI leaves you a note. See [Calendars](/docs/calendars/). | | `list_api_keys`, `create_api_key`, `revoke_api_key` | Integration keys. Only you create and revoke them, in Settings → Keys: your AI lists them and tells you which one a system needs. | | `get_settings` | The settings document and its version, without its secrets. | | `update_settings` | Change settings: send only the sections that change, with `expected_version`; the rest keeps its values, and `null` removes a key. Networks are keyed by origin, so adding or switching off one leaves the others alone. Where alerts and emails go, switching webhooks or test mode on, the inbound email secret, who is trusted, and switching a network on are yours to do in the owner app. | | `get_networks` | The networks this inbox reports to and how each is doing: last ping, last error, the rules each applies, receipts published, waiting and held until it takes newer rules (changes agreed to a booking or order, and refunds, need rules version 6). | The server’s instructions to the model are short: never invent facts about availability or prices; read them first; give each system its own key; send an `idempotency_key` with every write, so a retry never does it twice; record a customer’s own cancellation with `record_cancel`, never `cancel_by_business`; a customer who asks for their data gets `export_customer`, one who asks not to be known to booking networks `stop_customer_networks`, and erasing one is yours; money is yours. Every refusal names the fields to fix. ## Time yes, money no [Section titled “Time yes, money no”](#time-yes-money-no) Your AI confirms bookings, proposes other times and answers customers, and does with money only what you let it, in code rather than in its instructions (Settings → Prices, and what your AI may agree to). Out of the box: any free time within seven days of the one asked for, at your catalogue’s price for that customer, and nothing else. What it offers outside your limits — a discount, a quote, a price the catalogue does not give, a time further away or outside your opening hours, a later delivery, a dearer price than you last offered, past the last round — is not sent: you find it as a draft on the item, with what it is outside, to send as it is, change or drop, and you are emailed at most once an hour for it. A reply it writes that names an amount of money you have not offered (in figures or in words; a catalogue price only beside what it prices), or something off a price, is kept as a note for you the same way. Only you send or drop a draft. It never learns your lowest prices, your limits or your rewards as numbers, so a customer cannot talk it into reciting one; it only learns, as a code, that something is outside them. It never records a payment, a failed payment, a charge-back or a refund, gives a payment link of its own, changes a price or your currency, publishes something with a price, connects or removes a feed, or writes a rule that sends a quote or whose words name an amount or a discount, or lifts the order value you accept in person; nor does it confirm a booking or accept an order whose request held a price your catalogue does not give. Each refusal tells it to leave you a note (`reply` with `internal: true`) with what it suggests. This holds for anything that comes through the owner’s MCP, even with a full owner key, and for a key you gave another system without `money:write`. With returns it may open one, record a withdrawal the customer wrote, and approve a return inside your return policy with the goods coming back, while you allow it (Settings → Returns, and who you are); refusing a return, disputing what came back, cancelling a paid order, refunding without the goods and the refund itself are yours. With changes to what was agreed it does what you allow in Settings: out of the box it may accept a customer’s change to a free time before your cutoff (the cancellation window unless you set one), at your prices, and may not ask a customer for a change; a price, a longer booking or anything past the cutoff is yours. The public server a customer’s assistant connects to at `/mcp` carries your business’s name, and speaks in your words and your customer’s language. When you propose another time, send a quote or suggest changes to an order, the assistant reads it with `get_item_status` and answers with `accept_offer` (only on its person’s clear yes to the terms), `decline_offer`, or `make_offer` with what they would change (`suggest_time` for another time); a price of their own reaches you as their message, and what you proposed still stands, unless you take customers’ own prices (Settings), when it is their answer for you to take or answer. When you ask for details, it sends them with `provide_details`. Before a priced booking or order binds the customer, the assistant shows them its summary and sends it again with its fingerprint on their yes; it can withdraw from a contract for them while a right of withdrawal runs (`withdraw_from_contract`, the statement first) and ask to send goods back (`request_return`). Once a booking is confirmed or an order accepted, the same doors ask you for a change (`suggest_time`, `make_offer`) and answer a change you ask for (`accept_offer`, `decline_offer`); what was agreed stands until both say yes.
# apaleo
> apaleo keeps your rooms, rates and reservations. Once an apaleo app is in place, you approve your Inbox on apaleo's own page, and it writes each stay to apaleo before it confirms anything. Until then a hosted inbox shows apaleo as Soon, and stays come to you as requests.
**Not switched on yet.** This connection is built and switched off. A hotel connects an app on apaleo’s own page, and the app is registered with apaleo by whoever runs the Inbox server; the hosted inboxes at surfingdog.ai don’t have one yet. A hosted inbox shows apaleo as **Soon**; on your own server it says **Needs setup** until its connect client is set. Until then, stays come to you as requests and you confirm each one. ## What it needs [Section titled “What it needs”](#what-it-needs) * **An apaleo connect client**, registered with apaleo, with an https address to come back to. Whoever runs the Inbox server holds its id and secret. * Then, for you: **Continue with apaleo**, approve on apaleo’s page, and choose which of your properties this Inbox is. A second way, where a hotel makes its own *simple client* in apaleo (Apps → Connected apps), is built too and stays off: apaleo recommends that way only for a hotel’s own internal apps. ## What apaleo is asked to allow [Section titled “What apaleo is asked to allow”](#what-apaleo-is-asked-to-allow) Read your rooms (unit groups), rate plans, rates and availability; read offers; read and manage reservations. Never a permission to force a booking past what is free, never payment accounts and never card authorizations. ## What it will do [Section titled “What it will do”](#what-it-will-do) | | | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Reads** | Your properties, their time zones and currencies. Unit groups of bedrooms and how many units each has. Rate plans sold direct whose guarantee is a 6 pm hold. What is free night by night and each night’s price; a stay’s own total from apaleo’s offer. | | **Writes** | Each stay, before it is confirmed, as a direct booking with a 6 pm hold, its reservations carrying our reference and a comment with what the guest agreed. Changes and cancellations through apaleo’s own actions. The guest as the primary guest, once the stay is confirmed. | | **Never** | A price: apaleo prices each stay from its rate plan. A card, a payment account, an authorization or a commission. | Rate plans whose least guarantee in apaleo is a card or a prepayment are left out: your Inbox never holds a card. After each write your Inbox reads back what apaleo says the stay comes to. When that differs from what the guest agreed, you get a note on the stay, so the desk checks the folio before charging anything more. ## How it keeps a room from being sold twice [Section titled “How it keeps a room from being sold twice”](#how-it-keeps-a-room-from-being-sold-twice) * **Written first, once.** Each booking is sent with a key of its own; apaleo answers the same request with the same booking for 24 hours, so a request sent again never books twice. After that, the booking is found by its reference. * **Refused when full.** apaleo refuses a night it cannot sell. The booking is never forced. * **Changed room by room.** For a stay of several rooms, if apaleo refuses the second, the first is put back, so the stay keeps its dates. If apaleo will not take the first back, you get a note that apaleo and your Inbox now hold the stay differently, to set right in apaleo. Rooms are matched by their own number, so the tenth comes after the second. * **Your own date.** Every time apaleo gives is read in your property’s time zone: a lodge east of the date line keeps its own dates. apaleo has no holding state for a reservation, so a stay held for a few minutes, or waiting for its deposit, is a reservation your Inbox cancels if it is not paid in time. ## Webhooks [Section titled “Webhooks”](#webhooks) Once the connection is kept (its first sync, a moment after you approve it), your Inbox subscribes to reservation events at its own address. apaleo sends that address a health check as it makes the subscription, and your Inbox answers it, so the subscription is switched on. A hosted inbox subscribes once it is live at your own address. apaleo does not sign its deliveries: the address is the secret, the account must be yours, and each reservation named is read back from apaleo before anything changes. ## Documentation [Section titled “Documentation”](#documentation) * API reference: * OAuth: * The apaleo Store:
# Beds24
> Connect your own Beds24 with an invite code. Your rooms and what is free come in, and each stay is written to Beds24 before anything is promised, so a room is never sold twice.
Built from Beds24’s documentation and tested against a stand-in; not yet verified on a live account. Your first stays are checked against Beds24 as they are written. For hotels, guesthouses, B\&Bs, hostels and holiday rentals that keep their rooms in Beds24. Your Inbox reads what Beds24 says is free, and writes each stay there first. Settings → **Property** → **Connect your system** → **Beds24**. ## Connect [Section titled “Connect”](#connect) 1. In Beds24, open **Settings → Marketplace → API** and press **Generate invite code** ([Beds24’s API page](https://beds24.com/control3.php?pagetype=apiv2)). 2. Choose these, as Beds24 names them: * **Bookings:** Read and Write * **Bookings personal:** Read and Write * **Bookings financial:** Read and Write * **Inventory:** Read * **Properties:** Read 3. Leave the IP address empty. If your properties are linked, tick **Allow linked properties**. 4. Paste the code here. It works for 24 hours. 5. Choose your property and match its rooms. Give every room a daily price in Beds24 (**Prices → Daily Price Rules**): a room without one is never offered or booked from here until it has one. ## What it does, and what it never does [Section titled “What it does, and what it never does”](#what-it-does-and-what-it-never-does) * **Your rooms, read once.** You choose which Beds24 property this is, and match each of its rooms to yours, or make them here, named and counted as Beds24 has them. A room you made here that is not in Beds24 is no longer offered once you connect: match it to keep it. * **Fees and taxes, the same total.** A room Beds24 adds a cleaning fee or tax to is pointed out: add the same in Taxes & fees, so guests here pay the same total. * **What is free, from Beds24.** Read from your Beds24 calendar every 15 minutes for the next two months, every few hours for the year, and again at the moment a guest’s assistant holds or books. * **At your Beds24 price.** Guests are quoted a daily price from your Beds24 calendar that is the whole room’s for a night: for the room’s full capacity, nothing added per extra guest, and no minimum stay, stay length or booking window of its own. The first such one (Daily price 1 before Daily price 2) is used for every night of a stay. A room priced per person, for fewer guests than it sleeps, or only for some stays, is on request: guests ask, and you confirm each one with its price. Beds24 does not price bookings an app makes, so each booking carries the total your guest agreed. * **A stay is written to Beds24 first.** Your Inbox checks the nights in Beds24, asks Beds24 to refuse them if the room is gone, writes the booking, then reads it back. If another channel took the room in that moment, your Inbox cancels its own booking and nothing is promised: the guest is told the room could not be booked. * **What was paid, for the front desk.** Each booking says what the guest paid in your Inbox and what is left to pay at the property. * **Cancelled, never deleted.** A cancelled stay is cancelled in Beds24 and stays there, as cancelled, with its history. * **Guests’ details only for confirmed stays,** and only to your own Beds24: their name as they gave it, their email and phone. * **Light on Beds24.** It shares your 100 credits per five minutes with your other apps and always keeps some for bookings. * **Never your money.** No card details, no deposit, no commission is ever sent to Beds24. If Beds24 also emails guests about new bookings, your Inbox already sends the confirmation: check those auto emails so guests get one, not two. Stays in a currency with three decimals (such as JOD, KWD or BHD) are not written to Beds24 yet: you confirm those yourself. ## A stay with a deposit due [Section titled “A stay with a deposit due”](#a-stay-with-a-deposit-due) A stay with a deposit due goes into Beds24 as confirmed, flagged **Deposit due**, so the room is off sale on every channel. If the deposit isn’t paid in time, your Inbox cancels it. ## Confirming at once [Section titled “Confirming at once”](#confirming-at-once) A newly connected Beds24 starts with you confirming each request. Once a first stay has gone into Beds24 and been read back, you can switch on **Confirm at once when your system says a room is free**. If Beds24 adds a cleaning fee or tax to your rooms, you first say they are covered in Taxes & fees. ## Changes made in Beds24 [Section titled “Changes made in Beds24”](#changes-made-in-beds24) Changes made in Beds24 or on another channel are picked up within 15 minutes, and checked again the moment a guest books. ## What it reads, and what it writes [Section titled “What it reads, and what it writes”](#what-it-reads-and-what-it-writes) | | | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Reads | your properties (name, currency), their rooms (name, how many, how many sleep in one, fees and taxes set on them, each daily price’s rule and the offer that sells it), the calendar (units free, minimum and maximum stay, closed nights, no-arrival and no-departure nights, daily prices), how many bookings each room has on a stay’s nights, and the bookings your Inbox wrote | | Writes | one booking per room of each stay (a group for several rooms), with its dates, guests, price, your Inbox’s reference, a note, and what was paid; for a confirmed stay, the guest’s name, email and phone; later, its new dates or its cancellation | | Never keeps | your Beds24 bookings from other channels, or their guests | Every call goes to `beds24.com` and nowhere else. ## Disconnecting [Section titled “Disconnecting”](#disconnecting) Disconnecting gives your Inbox’s access back to Beds24 at once. Bookings already written stay in Beds24: they are yours. ## Built from [Section titled “Built from”](#built-from) Built from Beds24’s [API V2 documentation](https://wiki.beds24.com/index.php/API_V2.0) and [OpenAPI file](https://beds24.com/api/v2/apiV2.yaml).
# Share your bookings
> A private calendar link to your confirmed bookings, for a booking site's calendar sync, Google Calendar or a phone. When you are booked and for what, never who booked.
Your Inbox makes a private link to a calendar of your confirmed bookings. Paste it where another site or app imports a calendar by link: a booking site’s calendar sync, Google Calendar, a phone. What your Inbox books then closes that time there too. With a [calendar link](/docs/calendars/#a-calendar-link) the other way, a guesthouse on two booking sites, or a studio with a second booking page, keeps both sides closed for the same time. Settings → Integrations → **Share your bookings** → **Make a link**. ## Making the link [Section titled “Making the link”](#making-the-link) 1. Press **Make a link**. 2. Press **Copy link** on its line. 3. Paste it where the other site or app imports a calendar by link. Booking sites often call it *Import calendar* or *Sync calendars*; Google Calendar, *From URL*; a phone, *Subscribe*. Other sites fetch the link from your Inbox’s own https address, so it can be made once your Inbox has one: on your own server, set it under Settings → General, **Inbox address**; on a hosted Inbox, once it is live at its own address. Until then the card says **Needs this inbox’s address first**. The link is shown to you, signed in, and to nobody else: not to your AI, and not to keys you gave other systems. Opening the card again shows where it is: **Show my link**. ## What it shows [Section titled “What it shows”](#what-it-shows) * **Confirmed bookings**, from 30 days back to 18 months ahead. A cancelled one leaves the calendar; a moved one moves. * **The service’s name and the times.** A booking that covers whole days in your time zone is an all-day event, the way booking sites block nights. * **Never who booked**, how to reach them, or a price. A calendar link travels: anyone who has it can read it. Each booking keeps one id for its whole life, so the other side updates it rather than adding it twice. Your Inbox knows those ids for its own: a calendar that copies them back to it blocks nothing twice. The other side looks again on its own schedule: minutes on some, a few hours on others. Until then, a time just booked here is still open there. ## A new link, or none [Section titled “A new link, or none”](#a-new-link-or-none) * **New link**: the old one stops at once, wherever it was pasted. Paste the new one where the old one was. * **Disconnect**: the link stops at once. Your Inbox reads nothing and writes nothing anywhere for this: the link is the whole connection. It follows [RFC 5545](https://www.rfc-editor.org/rfc/rfc5545).
# Calendly
> Sign in with Calendly, or connect with a token you make in your own account. What is booked on Calendly is never offered twice, and on Calendly's paid plans bookings your Inbox confirms are held there too.
Built from Calendly’s documentation and tested against a stand-in; not yet verified on a live account. If you already take bookings on Calendly, your Inbox works with it. Times booked on Calendly, or busy in a calendar Calendly checks, are not offered to agents. On Calendly’s Standard plan and above, each booking your Inbox confirms is held on Calendly too, so nobody books that time there. Settings → Integrations → **Calendly**. ## Sign in with Calendly [Section titled “Sign in with Calendly”](#sign-in-with-calendly) **Continue with Calendly** opens Calendly’s own page, where you allow your Inbox to see when you are busy, read your event types and add the bookings it confirms. Nothing to copy or paste. It needs a Calendly app on the server that runs your Inbox. On a hosted Inbox it is coming soon; until then, use a token. On your own server, register an app at [developer.calendly.com](https://developer.calendly.com/docs/authentication/creating-an-oauth-app) (a web app, production environment) with the scopes below and the redirect `https:///oauth/connect/calendly/callback`, then set `CALENDLY_CLIENT_ID` and `CALENDLY_CLIENT_SECRET`. ## Or a token [Section titled “Or a token”](#or-a-token) Under **More options** (or straight away, while signing in is not set up here): 1. In Calendly: **Integrations & apps → API & webhooks → Generate new token** ([open Calendly](https://calendly.com/integrations/api_webhooks)). 2. Name it Inbox. Under scopes, tick: `users:read` · `organizations:read` · `event_types:read` · `availability:read` · `scheduled_events:write` · `webhooks:write` 3. Create it and copy it: Calendly shows it once. Paste it into your Inbox. The token is checked with Calendly before anything is kept. It is encrypted before it is stored, and never shown again, to you or to your AI. Then, under **Change**, choose the **Event type** bookings go to: **Same length as the booking** (the default), or one event type. ## What your Inbox reads and writes [Section titled “What your Inbox reads and writes”](#what-your-inbox-reads-and-writes) | | | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Reads** | Your account (name, email, booking page), your plan, your event types, and your busy times: your Calendly events and the calendars Calendly checks for conflicts. Never keeps your invitees’ details: a webhook’s body carries them, and only its ids are kept. | | **Writes** | On a paid plan, one Calendly event per booking your Inbox confirms, on a one-on-one event type of the same length. Moved in your Inbox: a new event, then the old one cancelled. Cancelled in your Inbox: cancelled on Calendly. | | **Webhook** | On a paid plan, one, at your Inbox’s own address, for bookings made or cancelled on Calendly. Each delivery is signed with a key made for it. | * **Your customer hears from you, not from Calendly.** Calendly emails whoever is booked, so the booking is made in your name, with your customer’s name on it. Calendly writes to you, and your Inbox has already told your customer. * **Event types it can use** are one-on-one, booked at once, with no required question and a place you set (an address, a video call, your phone). Group events, polls, and event types that ask the customer where to meet are left out. If none qualifies, the connection says so, and busy times still work. * **Lengths.** Calendly books an event type at its own length. If a service of yours has no event type that long, the connection names it (“No 45-min event type on Calendly: …”), so you can add one in Calendly. * **Busy times are read every five minutes**, about three months ahead, and at once when Calendly’s webhook says something changed. * **A booking made on Calendly blocks its time here.** It stays on Calendly: it does not become a booking in your Inbox. * **Calendar too?** Calendly adds its bookings to the calendar it checks. If that calendar adds your Inbox’s bookings as well, Settings offers to stop one of them, so each shows once. * Calendly cannot rename a booking: when a customer is erased, their name stays on the Calendly events already made. ## Calendly’s plans [Section titled “Calendly’s plans”](#calendlys-plans) | | Free | Standard and above | | ------------------------- | ---- | ------------------ | | Busy times | yes | yes | | Bookings held on Calendly | no | yes | | Webhook | no | yes | Your Inbox reads your plan when you connect. On Free, Calendly lets other apps read, not book ([Calendly](https://developer.calendly.com/api-docs/calendly-api/scheduled-events/create-event-invitee)), so the connection starts with **Add confirmed bookings** off and says **Calendly Free: busy times only**. Block times your Inbox books on Calendly yourself, or upgrade there: the connection notices within six hours, and you switch bookings on under **Change**. Without the webhook, the five-minute sync covers it. Calendly limits how many bookings other apps make: 10 a minute and 100 a day on a paid plan ([rate limits](https://developer.calendly.com/api-docs/overview/rate-limits)). Past that, your Inbox waits and tries again. ## Disconnecting [Section titled “Disconnecting”](#disconnecting) Settings → Integrations → **Calendly** → **Disconnect**. Your Inbox stops reading at once, removes its webhook from Calendly, and gives back the access you allowed when you signed in. Events already made on Calendly stay there: they are yours now. A token can only be revoked in Calendly: **Integrations & apps → API & webhooks**, then **Revoke** beside it.
# Cloudbeds
> Cloudbeds keeps your rooms, rates and reservations. Once a Cloudbeds partner app is in place, you connect your Inbox from Cloudbeds, and it writes each stay to Cloudbeds before it confirms anything, checking the nights before and after. Until then a hosted inbox shows Cloudbeds as Soon, and stays come to you as requests.
**Not switched on yet.** This connection is built and switched off. Cloudbeds asks each technology partner to be evaluated by its partnerships team before it connects to properties, and the hosted inboxes at surfingdog.ai don’t have a partner app yet. A hosted inbox shows Cloudbeds as **Soon**; on your own server it says **Needs setup** until its partner app is set. Until then, stays come to you as requests and you confirm each one. ## What it needs [Section titled “What it needs”](#what-it-needs) * **A Cloudbeds partner app**, approved by Cloudbeds, with its key delivered automatically when a property connects it. Whoever runs the Inbox server holds the app’s id and secret. * Then, for you: **Continue with Cloudbeds**, approve the app, and choose which of your properties this Inbox is. Cloudbeds hands your Inbox a key of your property’s own, which is encrypted before it is stored and never shown again. A second way, where you paste a key you made in Cloudbeds (Account → Apps & Marketplace → API Credentials), is built too and stays off until Cloudbeds’ partner team agrees to it. The scopes asked for: `read:hotel`, `read:room`, `read:rate`, `read:taxesAndFees`, `read:reservation`, `write:reservation` and `write:guest`. ## What it will do [Section titled “What it will do”](#what-it-will-do) | | | | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Reads** | Your properties, their time zones and currencies. Room types, shared rooms such as a dorm’s beds too, and how many rooms each has. Rate plans without a promo code, what is free night by night and each night’s rate. Your taxes and fees, to know whether a rate is what a guest pays. The reservations it made, and the ones on the nights it writes. | | **Writes** | Each stay, before it is confirmed, with our reference, unconfirmed while its deposit is due and confirmed once paid. The guest’s name, email and phone on the reservation once it is paid, so the desk has them at check-in. A note with the stay’s reference and what the guest agreed. Changes of dates or rooms. Cancellations, by status. Its webhooks. | | **Never** | A price: Cloudbeds prices each stay from its rate. A card, a card token or a payment authorization: the payment method is *cash*, meaning you are paid outside Cloudbeds. An email from Cloudbeds to your guest. | ## Prices, taxes and fees [Section titled “Prices, taxes and fees”](#prices-taxes-and-fees) Cloudbeds gives its rates before your own taxes and fees. If your property adds a tax or a fee on top of its rates, your Inbox reads your nights without prices, so a guest is never quoted less than their folio will show: you price those stays yourself. After each write your Inbox reads back the reservation’s own total, taxes and fees in, and if it differs from what the guest agreed you get a note on the stay, so the desk checks the folio before charging anything more. ## How it keeps a room from being sold twice [Section titled “How it keeps a room from being sold twice”](#how-it-keeps-a-room-from-being-sold-twice) Cloudbeds’ documentation does not say that it refuses a booking for a night with no room left, so your Inbox checks on both sides: * **Before.** The nights are read live just before a stay is written. A night with no room left is refused, and nothing is written. * **After.** Once Cloudbeds has taken the stay, your Inbox counts the reservations against the rooms on each of its nights: every page of them, each with its own rooms, cancelled ones left out, and its own booking among them, or it promises nothing yet. If another channel took the last room in the seconds between, your Inbox cancels its own booking at once, and nothing is promised: the guest is told the room could not be booked, and may ask again. * **Never refused once written.** Once Cloudbeds has made the reservation, a status or a note it does not take never turns into a no for the guest while Cloudbeds holds the room. * **Found again.** Each stay carries our reference, so a booking whose answer was lost is found, and never written twice; one cancelled in Cloudbeds since is never taken for the stay. * **Each room by its kind.** A stay of several rooms is matched room by room by its type and its guests, whatever order Cloudbeds lists them in (it lists assigned rooms first), so confirming a stay never swaps its rooms. ## Webhooks [Section titled “Webhooks”](#webhooks) Once the connection is kept (its first sync, a moment after you connect), your Inbox subscribes to reservation events at its own address, with a header of its own that Cloudbeds sends with every delivery. A hosted inbox subscribes once it is live at your own address. Your Inbox checks that header in constant time, and that the delivery is about your property, then reads the reservation back from Cloudbeds. A webhook is only a nudge: what is free is read again every few minutes anyway. ## Documentation [Section titled “Documentation”](#documentation) * Getting started as a partner: * API keys for partners: * Webhooks:
# Booked or ordered elsewhere
> Already take bookings on Fresha, Mews or TableCheck, or orders in your own Flipdish or Shopify checkout? Say where, and agents send people there, by name. Messages and price requests still come to your Inbox.
If people already book you on a booking platform, or order in your own shop’s checkout, keep it that way. Tell your Inbox where, and every AI agent that finds you sends people there for that, saying where by name: **Book at Fresha**, **Order at Flipdish**. Messages and requests for a price still come to your Inbox. Settings → General → **What agents can do** → **Take bookings or orders on another site?** ## Set it [Section titled “Set it”](#set-it) 1. Paste the link to your booking page or your shop: the one you would give a customer. A link from a platform in the list picks that platform for you. 2. If your platform gives you a direct booking link for your own website (Fresha, Treatwell and OpenTable do), paste that one rather than your page in its marketplace. When a network already found your page there, your Inbox starts from it: check it, and change it for your direct link if you have one. 3. For a shop on your own website (Shopify, WooCommerce®, Wix…), pick it from the list. The link starts from your website; add your website in General first, so agents know the shop is yours. 4. **Save.** WooCommerce and its associated designs are trademarks of Automattic Inc. Tracking at the end of a link (`utm_source`, `fbclid`, `gclid` and the like) is taken off as you paste it: agents send people to the page itself. Your Inbox says so before you save when a link is not your page there: a link that is not on the platform you picked, one of the platform’s own pages (its home, its sign-in, its prices) rather than yours, or, for a shop on your own website, a page that is not on it. A page of yours on a platform the list doesn’t know goes under **Another page**, named by its address. ## The places in the list [Section titled “The places in the list”](#the-places-in-the-list) * **Salons and wellness**: Fresha, Booksy, Planity, Treatwell, Timely, Phorest, Vagaro. * **Appointments**: Calendly, Cal.com, SimplyBook.me, Setmore, Acuity Scheduling, Square Appointments. * **Restaurant tables**: OpenTable, Resy, SevenRooms, TheFork, CoverManager, Tock, TableCheck, Chope. * **Food ordering**: Toast, ChowNow, Slice, Flipdish, GloriaFood. * **Shops and websites**: Shopify, WooCommerce, Wix, Square Online. * **Trades**: Tradify. * **Hotels and rentals**: SiteMinder, Cloudbeds, Mews, Beds24, Lodgify. Anything else, a page on your own website included, goes under **Another page**: any https page that is yours. What a platform charges you is between you and it. Your Inbox takes nothing and adds nothing to the link. ## What changes [Section titled “What changes”](#what-changes) * **Agents** read it in your [manifest](/docs/manifest/) and your business profile, over the API and MCP: the kind, the link, and the platform it is on. An agent that asks your Inbox to book or order anyway is told where to go, and nothing is written. * **Your page** shows **Book at Fresha** (or the page’s address, for a page on your own website) in place of times to pick ([Your page](/docs/your-page/)). * **Your Inbox** stops offering that kind itself. Messages, and requests for a price when you take them, stay. You can still add a booking or an order yourself. ## What your Inbox reads and writes [Section titled “What your Inbox reads and writes”](#what-your-inbox-reads-and-writes) Nothing at the platform. There is nothing to sign in to and no key to paste: a door elsewhere is a link. Your Inbox never calls the platform, never changes anything there, and never adds anything to the link. ## Take it back [Section titled “Take it back”](#take-it-back) **Take bookings here** on its line brings bookings back to your Inbox at once (they need a service and opening hours to open), and agents are told within minutes. **Remove** stops sending orders there. There is nothing to undo at the platform. ## For developers [Section titled “For developers”](#for-developers) The setting is `doorsElsewhere` in the settings document (`PUT /v1/owner/settings` with an owner key): at most one entry per kind, `{"itemType": "booking" | "order", "url": "https://…", "platform"?: "fresha"}`. The owner in person or a key with `settings:write` sets it, never the owner’s AI. It is published as the manifest’s `doors_elsewhere` ([spec](/spec/manifest/)); the platforms are [`vocab/door-platforms.json`](https://github.com/surfingdogai/inbox/blob/main/packages/spec/vocab/door-platforms.json) in `@surfingdog/spec`, with `doorPlatformOf`, `doorPlace` and `cleanDoorUrl`.
# Hostaway
> Connect Hostaway with your own account ID and API key. Your listings and free nights come in, every booking goes into Hostaway first, and your Inbox never takes a card, sets a price or charges a commission.
Built from Hostaway’s documentation and tested against a stand-in; not yet verified on a live account. Hostaway keeps your listings, their calendars and your reservations. Connected, your Inbox reads what is free from Hostaway and puts every booking into Hostaway first, so a night is never sold twice. Settings → **Integrations** → **Property system** → **Hostaway**. ## Connect it [Section titled “Connect it”](#connect-it) 1. Open Hostaway → **Settings → Hostaway API → Create**. Name it **Surfing Dog Inbox** and pick **Hostaway Public API** as the partner. 2. Copy the **account ID** and the **API key**. The key is shown only once. 3. In your Inbox: Settings → **Integrations** → **Property system** → **Hostaway**. Paste both and press **Connect**. The key is sealed: it is never shown again, to you or to your AI. 4. In Settings → **Property**, press **Choose the property**: pick your property in Hostaway and match each listing to one of your rooms, or make the rooms here, named as Hostaway has them. ## Good to know [Section titled “Good to know”](#good-to-know) * **One listing is one room.** Listings in the same town, currency and time zone are one property: flats in Osaka and in Tokyo are two. * **A stay of several rooms** is one reservation per room in Hostaway, written whole or not at all: if one room cannot be booked, the others are cancelled again. * **Every booking goes into Hostaway first.** Your Inbox checks the nights in Hostaway just before, and right after. If another channel took a night in that moment, your Inbox cancels its own reservation and the guest is told it did not go through. * **At first you confirm each booking yourself.** Instant booking can be switched on once one booking has gone into Hostaway and come back. * **Some things are done in Hostaway.** A change of room, a room added to or taken from a booked stay, and a cancellation Hostaway does not take: you are told on the stay. * **Never a card, a price or a commission.** The guest pays you, at the price you set. **Check your Hostaway automations.** Bookings from your Inbox are direct bookings in Hostaway, with the guest’s email once confirmed. If Hostaway sends messages or payment requests for direct bookings, the guest may get them too, including a request to pay for a stay already paid through your Inbox. A confirmed booking goes into Hostaway with the guest’s details before your Inbox has read it back, so in the rare moment another channel takes the same night, Hostaway may write to the guest before your Inbox cancels its booking. Look at Hostaway’s automated messages and payment rules for the direct channel. ## Disconnecting [Section titled “Disconnecting”](#disconnecting) The webhook is removed and the access is given back. To end it in Hostaway too, delete the **Surfing Dog Inbox** key in **Settings → Hostaway API**. Reservations already written stay in Hostaway: they are yours. ## Not yet [Section titled “Not yet”](#not-yet) * **A multi-unit listing** is sold one unit at a time. * **Guest messages** from Hostaway in your Inbox. Built from Hostaway’s public API reference, . See [Property systems](/docs/property-systems/) for what every connection does.
# Mews
> Mews keeps your rooms, rates and reservations. Once a partner token is in place, your Inbox reads what is free from Mews and writes each stay to Mews before it confirms anything. Until then a hosted inbox shows Mews as Soon, and stays come to you as requests.
**Not switched on yet.** This connection is built and switched off. Mews gives its Connector API only to certified partners, and the hosted inboxes at surfingdog.ai don’t have a partner token yet. A hosted inbox shows Mews as **Soon**; on your own server it says **Needs setup** until its partner token is set. Until it is in place, stays come to you as requests and you confirm each one. ## What it needs [Section titled “What it needs”](#what-it-needs) * **A partner token** (Mews calls it a *ClientToken*). Mews issues it to an integration it has certified, and it is the same for every property. Whoever runs the Inbox server holds it, never a property and never the code. * **Your property’s own token** (an *AccessToken*). Mews makes it when your property connects the integration in Mews Marketplace, and sends it to the integration’s owner. On a server you run yourself, you paste it in the connection’s form. Mews’s public demo environment, with its published tokens, is never called. ## What it will do [Section titled “What it will do”](#what-it-will-do) | | | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Reads** | Your property’s name, time zone and currency. Its bookable services counted in nights, their room categories and how many rooms each has, the public rates, what is free night by night and each night’s price. The reservations it made, to find them again and keep them in step. | | **Writes** | Each stay, before it is confirmed, with Mews refusing a night it has no room for. A stay that waits for its deposit is held in Mews as *optional*, with a time Mews lets it go by itself. Confirmation once paid; the guest’s name and contact only then. Changes and cancellations. A note on each reservation with the stay’s reference and what the guest agreed. | | **Never** | A price: Mews prices each stay from its own rate. A card, or anything that carries one. An email from Mews to your guest: your Inbox writes to them, in your words. | After each write your Inbox reads back what Mews says the stay comes to. When that differs from what the guest agreed (another rate, another number of guests), you get a note on the stay, so the desk checks the folio before charging anything more. ## How it keeps a room from being sold twice [Section titled “How it keeps a room from being sold twice”](#how-it-keeps-a-room-from-being-sold-twice) * **Written first.** A stay is confirmed only after Mews has taken it and said so. If Mews refuses, nothing is promised, and the guest may ask again. * **Held by Mews itself.** A stay held for a few minutes, or one waiting for its deposit, blocks the room in Mews as an optional reservation. If your Inbox ever fails to let it go, Mews releases it at the time it was given: an hour for a hold, three days at most for a deposit, and never after the arrival. * **Found again.** Each reservation carries one line in its notes: the stay’s reference (*Inbox stay 4F2K9Q*), what the guest agreed, and a tag only your Inbox can make. A reservation Mews let go or cancelled since is never taken for the stay. If Mews takes a stay but its answer is lost, your Inbox finds it by that line, and never writes it twice. * **A change checked twice.** The new nights are read first, counting the rooms the stay already holds as its own: a night with no room is refused before anything is written, so Mews never shows it oversold to its channels. Read again just after: if another booking took the room in between, the move is put back as it was (its dates, rooms, rate and guests) and refused, and the stay keeps its dates in both places. If Mews will not take it back, you get a note that Mews and your Inbox now hold the stay differently, to set right in Mews. * **Each room by its kind.** A stay of several rooms is matched room by room by its category and its guests, whatever order Mews lists them in. * **Cancelled, never deleted.** No cancellation fee is posted in Mews for a cancellation your Inbox makes. ## Limits, and no webhooks yet [Section titled “Limits, and no webhooks yet”](#limits-and-no-webhooks-yet) Mews allows 200 requests per property token in any 30 seconds. When it asks to wait, your Inbox waits as long as it says, and keeps answering from what it read last. Mews is read by polling only, for now: what is free over the next two months every 15 minutes, the year ahead every six hours, and the nights of a stay live, just before it is written. Mews sends its webhooks to one address for a whole integration, shared by every property connected to it, while each Inbox connection has an address of its own; webhooks wait for one route that hands each delivery to the property it is about. ## Documentation [Section titled “Documentation”](#documentation) * Connector API: * Certification:
# Shopify
> Connect your own Shopify shop with an app you make for it. Your products come in; a quote or order you accept becomes a draft order, paid at your own Shopify checkout. Shopify keeps your cart and checkout; your Inbox adds messages, quotes and receipts.
Built from Shopify’s documentation and tested against a stand-in; not yet verified on a live account. Shopify already lets assistants shop your cart and pay at your checkout. Your Inbox adds what Shopify does not: questions, quotes and bookings in one place, and a signed receipt for each one. Agents are sent to your Shopify checkout to buy, and to your Inbox for the rest. Settings → Integrations → **Shopify**. ## What it does, and what it never does [Section titled “What it does, and what it never does”](#what-it-does-and-what-it-never-does) * **Your products come in.** Every variant, priced in your shop’s currency, in stock when Shopify counts it, on sale when its product is active. Read again every few minutes, in full every six hours, and at once when Shopify says a product changed. * **An accepted quote or order becomes a draft order** in your shop, at the prices your customer accepted. The draft’s own checkout is the link to pay: your customer pays on Shopify, to you, with your payment methods. * **Your customer hears from you once.** Your Inbox emails the link in your name. It never asks Shopify to send its invoice email too. * **Nobody pays more than they accepted.** A delivery fee is a line of the order, so the checkout adds no rate on top, and no discount code. If your shop adds tax at checkout, the link is held, and you can propose the shop’s total to your customer, who accepts it or not. * **Paid means Shopify said so.** A webhook is checked against your app’s signature, then the order is read back from Shopify before anything is marked. Shopify’s webhooks carry ids only, never your customer’s details. Your Inbox also asks Shopify itself every few minutes. * **What happens in Shopify follows.** Paid, fulfilled (with its tracking), delivered and refunded move the order on here. A draft you delete in Shopify before it is paid closes its link, and the order waits for you. * **Never your money.** No card details, no fee, no share. Refunds are made in Shopify. ## Connect [Section titled “Connect”](#connect) You make a small app for your own shop in Shopify’s Dev Dashboard, and paste its two keys. Nobody else’s app stands between your shop and your Inbox. 1. Open the [Dev Dashboard](https://dev.shopify.com/dashboard/) with the account that owns your shop. **Create app** → **Start from Dev Dashboard**. Name it Inbox, then **Create**. 2. **Versions**: keep the **App URL** Shopify suggests, choose the newest **Webhooks API version**, and tick the scopes `read_products`, `read_orders` and `write_draft_orders`. **Release**. 3. **Install app** (on the **Installs** card), pick your shop, **Install**. 4. **App settings** → **Credentials**: copy the **Client ID** and the **Client secret**. 5. In your Inbox: Settings → Integrations → **Shopify**. Paste the Client ID and the Client secret, then your shop’s address: your Shopify admin’s address from the browser, or the one ending in `.myshopify.com`. **Connect**. An app you make for your own shop can read its orders and write your customer’s details onto a draft: Shopify gives that to an app in your own organization without a review. Your Inbox asks Shopify for a token that lasts a day, and asks again before it lapses. **An app made in your Shopify admin before 2026** still works: under More options, paste its Admin API access token. Paste its API key as the Client ID and its API secret key as the Client secret. Shopify signs its updates with that secret, so a wrong one stops them reaching your Inbox (it still asks Shopify itself every few minutes). ### Sign in with Shopify [Section titled “Sign in with Shopify”](#sign-in-with-shopify) Only on your own server: make an app in the Dev Dashboard with the scopes above, add `https:///oauth/connect/shopify/callback` as its redirect URL, and set `SHOPIFY_CLIENT_ID` and `SHOPIFY_CLIENT_SECRET`. A **Sign in with Shopify** button then appears beside the keys. ## Send agents to your checkout [Section titled “Send agents to your checkout”](#send-agents-to-your-checkout) Your shop’s checkout is where carts are bought. Name it for orders in Settings → General → **What agents can do** ([Booked or ordered elsewhere](/docs/connectors/elsewhere/)), and your manifest sends agents there to buy, and to your Inbox for questions and quotes. ## What it reads, and what it writes [Section titled “What it reads, and what it writes”](#what-it-reads-and-what-it-writes) | | | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Reads | your shop’s name, currency and whether prices include tax; products and variants (name, SKU, barcode, vendor, price, stock, on sale, the product’s page on your online store and its picture’s link); the draft orders it made and the orders they became (paid, fulfilled, refunded) | | Writes | draft orders, tagged `inbox`; the webhooks below, at your connection’s own address | | Never keeps | your customers, or any order it did not make. Shopify tells your Inbox when any order changes, with its id and tags only; your Inbox looks only at orders made from its own drafts, and keeps their ids, nothing else. | Webhooks it makes, each carrying ids only: `orders/paid`, `orders/fulfilled`, `orders/updated`, `orders/cancelled`, `refunds/create`, `draft_orders/delete`, `products/create`, `products/update`, `products/delete`, `app/uninstalled`. A hosted inbox makes them once it is live. If Shopify refuses one, your Inbox connects anyway and tries again within the hour. Shopify removes one after eight failed deliveries in four hours; your Inbox puts it back within six hours, and checks orders itself meanwhile. A draft carries your customer’s name, email, phone (when written as +country code) and address: what fulfilling needs, to your own shop, and nothing more. ## Limits [Section titled “Limits”](#limits) * **Money in hundredths.** A shop that sells in a currency counted otherwise (yen, won, dinars) cannot be connected yet. * **5,000 products** a connection. Past that, the first 5,000 stay and you are told once. * **Shopify’s rate limit.** Every call is in Shopify’s Admin API, version 2026-10, within your shop’s points per second. When the bucket runs low your Inbox waits a moment, or comes back a little later. * **A product’s own change** (taken off sale, renamed) is read for its first 25 variants at once, and for the rest at the next full read. * **60 days of orders.** Shopify shows an app only the last 60 days of orders. Your Inbox follows an order for 60 days after it is paid; a refund made in Shopify after that is not read back, so mark it done in your Inbox yourself. * **A development store** connects in test mode: it takes only test orders, so only orders made in Test mode get a link. ## Disconnect [Section titled “Disconnect”](#disconnect) Settings → Integrations → **Shopify** → **Disconnect**. The webhooks it made are removed, open links are closed, and your products here are taken off sale (never deleted: an order may name one). Open draft orders your Inbox made, and nobody paid, are deleted in Shopify. Orders and paid links stay. To end the app’s access too, uninstall it in Shopify: **Settings** → **Apps** → the app’s menu → **Uninstall**. ## Shopify’s own docs [Section titled “Shopify’s own docs”](#shopifys-own-docs) * [Client credentials grant](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant) * [Create apps in the Dev Dashboard](https://shopify.dev/docs/apps/build/dev-dashboard/create-apps-using-dev-dashboard) * [Protected customer data](https://shopify.dev/docs/apps/launch/protected-customer-data) * [GraphQL Admin API](https://shopify.dev/docs/api/admin-graphql) and [its rate limits](https://shopify.dev/docs/apps/build/apis/graphql-admin/rate-limits) * [draftOrderCreate](https://shopify.dev/docs/api/admin-graphql/2026-10/mutations/draftOrderCreate) * [HTTPS webhooks](https://shopify.dev/docs/apps/build/webhooks/subscribe/https) * [Uninstalling apps](https://help.shopify.com/en/manual/apps/uninstalling-apps)
# Smoobu
> Connect Smoobu with your own API key and secret. Your apartments and free nights come in, every booking goes into Smoobu first, and your Inbox never takes a card, sets a price or charges a commission.
Smoobu keeps your apartments, their calendars and your bookings. Connected, your Inbox reads what is free from Smoobu and puts every booking into Smoobu first, so a night is never sold twice. Smoobu is in Settings → **Integrations** → **Property system**. If it says **Soon**, your Inbox does not have it yet, or it is waiting on Smoobu’s go-ahead: Smoobu asks software that connects many accounts to get in touch first. ## Connect it [Section titled “Connect it”](#connect-it) 1. Open Smoobu → **Settings → Advanced → API Keys → Create API Key**. Name it **Inbox**. 2. Press **Generate Secret**, and copy the **API key** and the **API secret**. Smoobu shows the secret only once. 3. In your Inbox: Settings → **Integrations** → **Property system** → **Smoobu**. Paste both and press **Connect**. They are checked with Smoobu first, then sealed: never shown again, to you or to your AI. 4. In Settings → **Property**, press **Choose the property**, pick yours from Smoobu, and match each apartment to a room, or let your Inbox make the rooms with Smoobu’s names. At first you answer every request yourself. Once a first stay has been written to Smoobu and read back, you can switch on confirming at once. ## What it reads [Section titled “What it reads”](#what-it-reads) * **Each apartment is a room, of one.** Apartments that share a currency, a time zone and a town are one property here. Three rooms in Jaipur and a cottage in Udaipur show as two: *Jaipur · 3 apartments*, and the cottage by its own name. * **Who it sleeps, and its beds.** Smoobu says how many an apartment sleeps, not how many may be children, so all but one guest may be. Change it on the room if that is wrong. * **Every night, as its rates have them.** Free or taken, its minimum stay, and its nightly price, as one rate: *Smoobu’s nightly price*. * **In the apartment’s own currency.** Prices stay exactly as Smoobu has them. One with more decimals than its currency allows is not offered. * **An apartment missing from the list** has no currency in Smoobu yet: give it one there. Your Inbox reads up to 200 apartments from one account. ## What it writes [Section titled “What it writes”](#what-it-writes) * **Checked first, read back after.** Just before each booking, your Inbox asks Smoobu whether the nights are free and whether its rules (minimum stay, guests) take the stay. Right after, it reads the bookings on those nights, blocked periods and grouped apartments included. If another channel took a night in that moment, your Inbox cancels its own booking and nothing is promised to the guest. * **As a Homepage booking,** Smoobu’s default channel for bookings made through its API, at the price your Inbox quoted. * **The guest’s name, email, phone and requests** go only once the stay is confirmed, and only to Smoobu. * **The booking’s notes** start with a line like *Direct booking · ref HR4K2Q \[#01J…]*, in your Inbox’s language, or English. Leave that first line: it is how a booking is found again after an answer that never came, so the same stay is never written twice. If you rewrite the notes, your Inbox leaves them as you wrote them. * **No hold.** Smoobu documents none. A stay with its deposit still due is an ordinary booking, marked *deposit due* in its notes, which keeps its nights until it is paid or cancelled. * **Never** a card, a payment or deposit status, a fee or a commission. **Check your Smoobu automations.** Messages or emails you set up for Homepage bookings will run for these too. A confirmed booking goes into Smoobu with the guest’s details before your Inbox has read it back, so in the rare moment another channel takes the same night, Smoobu may write to the guest before your Inbox cancels its booking. If your Smoobu booking form requires names, a stay whose deposit is still due has none yet: Smoobu refuses it, and the stay stays a request for you to answer. ## Changes and cancellations [Section titled “Changes and cancellations”](#changes-and-cancellations) * **Guests, the price and the guest’s details** are changed in Smoobu too. * **Other nights or another apartment.** Smoobu does not let another system move a booking, so the change is refused and the stay keeps its nights in both places. Change it in Smoobu and in your Inbox. * **A booking you move or cancel in Smoobu** frees or takes its nights in your Inbox at the next read, but the stay in your Inbox keeps its dates and stays confirmed. Change or cancel it in your Inbox as well. * **Cancelling** uses Smoobu’s own cancellation, which keeps the booking in Smoobu as cancelled. If Smoobu does not take it, you are told on the stay: cancel it in Smoobu. ## What it hears [Section titled “What it hears”](#what-it-hears) * **One webhook, made for you.** Connecting registers your Inbox’s address with Smoobu, with a second secret made for this connection alone. You add nothing. If your Inbox moves to its own domain, the webhook moves with it. * **Only what it needs.** Smoobu sends every event to every address. Your Inbox reads booking and rate events for your account and leaves the rest. * **A hint, never the truth.** A webhook says which booking or nights changed; your Inbox reads them again from Smoobu. ## Disconnecting [Section titled “Disconnecting”](#disconnecting) The webhook is removed. To end it in Smoobu too, delete the key in **Settings → Advanced → API Keys**. Bookings already written stay in Smoobu: they are yours. ## Not yet [Section titled “Not yet”](#not-yet) * A stay of several rooms. Smoobu keeps one apartment per booking, so the guest asks for each room on its own. * Guest messages from Smoobu in your Inbox. * A stay that follows a booking moved or cancelled in Smoobu (see above). ## For developers [Section titled “For developers”](#for-developers) **Limits.** Smoobu takes 700 requests a minute. `X-RateLimit-Retry-After` is the window’s end as a Unix time, and your Inbox waits until then when asked. The list of apartments is read live; their details are kept from a refresh for twelve hours; a quote reads two apartments, never the whole account. **Signing.** Every request carries `X-API-Key`, `X-Timestamp`, a one-time `X-Nonce` and `X-Signature`, as Smoobu documents them. The older `Api-Key` header stops working on 31 October 2026 and is never sent. **Switched off until Smoobu’s go-ahead.** `SMOOBU_READY` in `packages/adapters/src/connectors/property/smoobu.ts` is false in this release, so the gallery shows **Needs setup** with nothing to press. Smoobu’s reference asks anyone whose software serves more than one Smoobu user to contact it first. Still to confirm with Smoobu: whether webhook deliveries are signed; whether `from` and `to` on the bookings list mean overlap or arrival (the read-back covers both); what a failed sign-in looks like from `checkApartmentAvailability` (taken as the key, never as a full night); and whether channel 70 (Homepage) or 13 (Direct booking) suits these bookings. **How it was built.** From Smoobu’s API reference, , as it read on 20 October 2026, and checked against a local fake of Smoobu built from it, never against a real account. The adapter, its fake and the excerpt behind each recorded answer are in the repository (`packages/adapters/src/connectors/property/smoobu.ts`, `packages/adapters/test/`). See [Property systems](/docs/property-systems/) for what every connection does.
# Square
> Connect your own Square account. Busy times and bookings stay in step with Square Appointments, your items come in, and an order you accept is paid at your own Square checkout.
Built from Square’s documentation and tested against a stand-in; not yet verified on a live account. For salons, barbers, cafés, restaurants, trades and gyms that run their day on Square. Your Inbox reads your Square, and writes into it what you confirm. Settings → Integrations → **Square**. ## What it does, and what it never does [Section titled “What it does, and what it never does”](#what-it-does-and-what-it-never-does) * **Busy times come from Square.** What Square would not book (closed hours, time off, a team member already booked) is taken off the times your Inbox offers. Read every few minutes, and at once when Square says a booking changed. * **A booking you confirm goes into Square**, with whoever Square says is free then, or the team member you chose. Moved or cancelled here, it moves or goes there too. Through **Sign in with Square** this works on every plan; with an access token, on Appointments Plus or Premium (see [Limits](#limits)). * **Square never messages your customer.** Square texts and emails the customer of every booking an app makes. So each customer of your Inbox has a record in Square under their name and your location’s own phone number, with no email: your Square calendar shows who is coming, and Square’s messages reach you. * **Your items come in.** Each variation, priced and counted at your location, on sale while Square sells it there. * **An order you accept becomes a Square order** with its own link to pay: Square’s checkout, in your name, paid into your account. A pickup at its time (or as soon as you can), or a shipment to your customer’s address. * **Nobody pays more than they accepted.** If Square adds the tax you set on an item, the link is held, and you can propose Square’s total to your customer, who accepts it or not. * **Paid means Square said so.** A webhook is checked against its signature, then the order and its payment are read back from Square before anything is marked. Only ids are kept. Your Inbox also asks Square itself every few minutes. A link is closed only while its order is unpaid. * **What happens in Square follows.** Ready, picked up, shipped (with its tracking) and refunded move the order on here. * **Never your money.** No card details, no fee (Square’s `app_fee_money` is never set), no share. Refunds are made in Square. ## Connect [Section titled “Connect”](#connect) **Sign in with Square** comes first where it is offered: one press, on Square’s own page. On a hosted Inbox it says **Soon**; until then, you make a small app of your own in Square and paste its token. Nobody else’s app stands between your Square and your Inbox. 1. Open the [Square Developer Console](https://developer.squareup.com/apps) with your Square login, and create an app. Name it Inbox. 2. In the app, open **Credentials**, switch to **Production**, press **Show** beside the access token and copy it. 3. In your Inbox: Settings → Integrations → **Square**. Paste it, then **Connect**. 4. If you have more than one location, choose one; and who takes bookings: **whoever is free**, or one team member. The token opens your whole Square account. Your Inbox only reads your business, items and team, and adds bookings and orders. A Sandbox token from the same page connects in test mode: only orders made in Test mode get a link. Square asks for a phone number on every booking. Give your location one in your Square Dashboard, under **Locations**. Until it has one, the location says so in **Change**. ### Sign in with Square [Section titled “Sign in with Square”](#sign-in-with-square) On your own server: make an app in the Square Developer Console, add `https:///oauth/connect/square/callback` as its redirect URL, and set `SQUARE_CLIENT_ID` and `SQUARE_CLIENT_SECRET`. **Sign in with Square** is then the first way to connect. Square keeps webhook subscriptions for an app’s own token, so a sign-in hears of changes by asking every few minutes. ## Which service a booking goes to [Section titled “Which service a booking goes to”](#which-service-a-booking-goes-to) By its name and length: a 45-minute **Haircut** here goes to the 45-minute **Haircut** in Square. If several Square services are as long and none has the same name, the booking is not added and your Inbox says which: name one in Square as it is here. A service with a cancellation fee cannot be booked by an app; Square says so, and so does your Inbox. ## What it reads, and what it writes [Section titled “What it reads, and what it writes”](#what-it-reads-and-what-it-writes) | | | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Reads | your business name; your open locations (name, currency, phone); whether Square Appointments is on, and your plan; your services, their length and who takes them; who is free when; items and variations (name, SKU, barcode, price, stock, the link to their picture, and their Square Online page); the orders, payments and refunds of the links it made | | Writes | bookings, and a customer record for each customer they are for (their name and your location’s phone, no email); orders with their links to pay; one webhook subscription, at your connection’s own address | | Never keeps | your Square customers, or any order or booking it did not make. A webhook is kept as its id, its topic and the ids it names. | Webhook topics: `booking.created`, `booking.updated`, `order.updated`, `order.fulfillment.updated`, `payment.updated`, `refund.updated`, `catalog.version.updated`, `inventory.count.updated`. A hosted inbox adds the subscription once it is live. An order carries your customer’s name, email, phone (when written with its country code) and address: what fulfilling needs, to your own Square, and nothing more. A booking carries only their name, on its customer record and in its note. ## Limits [Section titled “Limits”](#limits) * **Bookings and your plan.** Square adds bookings from an access token on Appointments Plus or Premium only. On Appointments Free your busy times still come from Square, your Inbox adds no bookings, and its row says why; Sign in with Square adds them on every plan. * **Bookings you approve.** If Square asks you to accept each booking, the ones your Inbox adds may wait for you in Square too. * **Busy times are read for one service**: the one most of your team take (the shortest of those), or the shortest the chosen team member takes. A service only some of your team take may still be refused at a time Square shows as free for another. * **A booking moved** to a time its team member is not free (an overlap with its own old time included) is booked anew with whoever is free, and the old one cancelled after. If Square will not cancel the old one (its cancellation window has closed), the move is not made: move it in Square. * **Every currency counted in cents, or in none.** Yen, won and other currencies without cents are priced exactly and paid in whole units. A currency counted in thousandths (Kuwaiti dinars) comes in with its items off sale, and gets no links. * **Items are priced by Square.** An item of your catalogue is sold at Square’s price; if your Inbox shows another, the link is held. * **5,000 products** a connection. Past that, the first 5,000 stay and you are told once. * **Each sync** asks Square up to three times, for the next 93 days, every five minutes. ## Disconnect [Section titled “Disconnect”](#disconnect) Settings → Integrations → **Square** → **Disconnect**. The webhook subscription is removed, open links are closed, and your products here are taken off sale (never deleted: an order may name one). Bookings and paid orders stay in Square. If you pasted an access token, delete your Inbox app in the Square Developer Console to switch it off; a sign-in is given back to Square at once. ## Square’s own docs [Section titled “Square’s own docs”](#squares-own-docs) * [Access tokens](https://developer.squareup.com/docs/build-basics/access-tokens) * [Bookings API](https://developer.squareup.com/docs/bookings-api/what-it-is) and [creating bookings](https://developer.squareup.com/docs/bookings-api/use-the-api) * [Catalog search](https://developer.squareup.com/docs/catalog-api/search-catalog-objects) * [Checkout with an order](https://developer.squareup.com/docs/checkout-api/square-order-checkout) and [deleting a link](https://developer.squareup.com/docs/checkout-api/manage-checkout) * [Fulfillments](https://developer.squareup.com/docs/orders-api/fulfillments) * [Monetary amounts](https://developer.squareup.com/docs/build-basics/common-data-types/working-with-monetary-amounts) * [Webhook subscriptions](https://developer.squareup.com/docs/webhooks/webhook-subscriptions-api) and [their signatures](https://developer.squareup.com/docs/webhooks/step3validate) * [OAuth](https://developer.squareup.com/docs/oauth-api/receive-and-manage-tokens)
# WooCommerce®
> Connect your WordPress shop with one press on its own Approve page. Your products and stock come in; orders you accept go into WooCommerce and are paid at your shop's own checkout.
Your WooCommerce® shop already has your products, your stock, your checkout and your payment methods. Connected, your Inbox uses them: agents see what you sell, and an order you accept goes into WooCommerce, where your customer pays at your own checkout. WooCommerce and its associated designs are trademarks of Automattic Inc. Tested on 1 November 2026 against a real WooCommerce 11.2.1 store (WordPress 7.1, run on our own machine): connected by its Approve page and by its keys; products, variations, sale prices, stock and pictures in; stock changes followed; orders written in, one paid at the store’s own checkout by bank transfer and marked paid once the shop took it as received, then completed and refunded, others cancelled from either side; tax included in prices and added at checkout; disconnected. Not yet on a business’s live shop. Settings → Integrations → **WooCommerce** → **Connect your shop**. ## Connecting [Section titled “Connecting”](#connecting) 1. Your shop’s address is filled in from Settings → Business website. Change it if your shop is somewhere else. 2. Press **Connect your shop**. WooCommerce opens its own Approve page in your WordPress admin (sign in there if it asks). 3. Press **Approve**. You come back to your Inbox, connected. WooCommerce makes a key for your Inbox, named *Agent inbox (inbox.yourdomain.com)*, and sends it straight to your Inbox. You never copy it, and nobody else holds it. It is encrypted before it is stored, and never shown again. Your Inbox checks it with your shop a moment later, on its first sync; if something is wrong, the connection says what. **With the WordPress plugin:** Settings → Agent inbox → **Connect WooCommerce** opens the same drawer in your Inbox. **Before your Inbox is live at its own address,** your shop can’t reach it to send the key. The drawer says so and opens on the keys below. **If the Approve page doesn’t work** (your WordPress uses plain permalinks): **More options** → **Paste the keys instead**. 1. In WordPress: WooCommerce → Settings → Advanced → REST API → **Add key**. 2. Description *Agent inbox*, Permissions **Read/Write**, then **Generate API key**. 3. Copy the consumer key and the consumer secret into your Inbox. Your shop must answer over https. Your Inbox calls nothing else. **Connecting again** (Change → **Approve again**) makes a new key. Your Inbox keeps its webhooks and reminds you, on the connection, to remove the earlier key. ## What it reads, and what it writes [Section titled “What it reads, and what it writes”](#what-it-reads-and-what-it-writes) | | | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Reads** | Your products and their variations: name, SKU, barcode (the GTIN, UPC, EAN or ISBN field), price, stock, whether they are on sale, their page and the link to their picture. Your shop’s currency, and whether its prices include tax. Your shop’s list of states or provinces, to write an address the way your shop does. The orders your Inbox made, and their refunds. Its own webhooks. | | **Writes** | The orders you accept, as **pending** orders. Its webhooks (three: orders updated, products updated, products deleted). A private note on each order it makes. | | **Never** | Orders your shop takes at its own checkout. Your customers’ accounts. Your prices, stock or settings. Card details: your customers pay at your checkout, with your payment methods. | ## Your catalogue [Section titled “Your catalogue”](#your-catalogue) * **What is on sale comes in.** Published, purchasable products and each variation of a variable product (“Pizza Calabresa – Large”). Drafts, private products and anything binned come in off sale. Grouped and external products are not sold by your checkout, so they are left out. * **Stock** is WooCommerce’s count where it counts stock, and out of stock where you marked it so. * **Prices** are your shop’s current prices, sale prices included, in your shop’s currency. * **Kept up to date.** What changed is read every few minutes, and at once when WooCommerce says a product changed; everything every six hours. A product you unpublish or bin comes off sale within minutes; one deleted for good, at the next full read. ## Orders [Section titled “Orders”](#orders) * **An order you accept goes into WooCommerce, pending,** at the prices your customer accepted, with their name, email, phone, address and note: what fulfilling it needs, in your own shop. WooCommerce sends your customer nothing for a pending order. * **The address as your shop writes it.** A state or province is written as your shop’s own code for it (ON, MH, SP), so your shop’s tax rates for it apply. * **Your customer pays at your checkout.** Your Inbox emails them, in your name, the order’s own link to pay in your shop. The money goes to your payment provider, as for any order in your shop. Your Inbox never touches it, and never adds a fee. * **Paid means WooCommerce says so.** When your customer pays, WooCommerce tells your Inbox; your Inbox asks WooCommerce, with your key, before it marks anything paid. Cash on delivery counts as paid once you complete the order. * **Completing the order in WooCommerce** marks it fulfilled here. Refunds stay in WooCommerce; a refund you make there marks the one your Inbox owes as done. * **If your shop cancels an unpaid order,** the link closes and the order waits for you here: your customer is not told their order ended. If you cancel it here first, the pending order in WooCommerce is cancelled too. * **Tax.** If your prices include tax, the order comes to exactly what your customer accepted. If your shop adds tax at checkout, the connection says so, and each order’s total comes to you first: you send your customer the shop’s total, who accepts it or not. Nobody pays more than they accepted. * **One shop sends the links.** With WooCommerce connected, orders are paid at your shop’s checkout, and Stripe or Mollie links stop. Links already sent keep working. **Your customer hears from you once.** With the WordPress plugin, WooCommerce’s own processing and completed emails for orders your Inbox made stay quiet, since your Inbox sends its own receipt and says when the order is on its way. Refunds and cancellations you make in WooCommerce are emailed by WooCommerce as usual, as are bank transfer details, invoices and your notes. Without the plugin, your customer may get both receipts. ## Disconnecting [Section titled “Disconnecting”](#disconnecting) Settings → Integrations → **WooCommerce** → **Disconnect**. Your Inbox removes its webhooks from your shop and takes its products off sale. Unpaid orders your Inbox made are cancelled in WooCommerce, since it can no longer hear that they were paid. Paid orders stay, in WooCommerce and here. WooCommerce doesn’t let an app remove its own key. Remove it yourself in WooCommerce → Settings → Advanced → REST API: the key named *Agent inbox*. ## Good to know [Section titled “Good to know”](#good-to-know) * WooCommerce sends its webhooks through WordPress’s scheduler, so on a quiet site they can arrive minutes late. Your Inbox also asks your shop every few minutes, so nothing is missed. * WooCommerce switches a webhook off after several failed deliveries. Your Inbox switches its own back on. * Orders in a currency your shop doesn’t sell in are not written into it. * Shops in currencies with no minor unit (yen, won, dong, Chilean pesos) work like any other. Currencies counted in thousandths (Kuwaiti or Bahraini dinars) can’t be connected yet. [WooCommerce REST API](https://woocommerce.github.io/woocommerce-rest-api-docs/) · [Webhooks](https://woocommerce.com/document/webhooks/) · [Keys](https://woocommerce.com/document/woocommerce-rest-api/)
# Contributing
> The licence, the toolchain, and the rules every change must respect.
The repository is [github.com/surfingdogai/inbox](https://github.com/surfingdogai/inbox). Read the decision records in `docs/adr/` before changing architecture; they are short and they explain why things are the way they are. ## Licence [Section titled “Licence”](#licence) Everything in the repository is **MIT**: the server and the app (`apps/*`, `packages/core`, `packages/platform`, `packages/adapters`, `packages/ui`), `packages/spec` (the manifest, receipt and network formats with test vectors), `packages/sdk` (the typed client), `packages/network-check`, `examples/network`, and the channels, connectors, AI and hosted packages as they arrive. Anyone can run it, change it, implement a compatible instance, agent, network or integration, and build on it; nothing they build has to be shared. The root `LICENSE` covers the whole repository. The packages published on their own (`packages/spec`, `packages/sdk`, `packages/network-check`) and `examples/network` carry a copy of it. ## Toolchain [Section titled “Toolchain”](#toolchain) pnpm 11 and Node 22.16, 24 or 26 (`node:sqlite` needs FTS5, which arrived in 22.16). TypeScript stays on 5.9 because TypeScript 7 has no JavaScript API for the tooling yet.
```bash
pnpm install
pnpm check && pnpm typecheck
pnpm test:node && pnpm test:workers # the same suite on Node and inside workerd
pnpm dev # the inbox on Node, no database set-up needed
pnpm dev:workers # the inbox in wrangler dev
pnpm kit # the design kit
```
## Rules of the repo [Section titled “Rules of the repo”](#rules-of-the-repo) * **One codebase, two runtimes.** Every change must pass `pnpm test:node` and `pnpm test:workers`. Files ending in `.node.test.ts` run only on Node, `.workers.test.ts` only inside workerd; everything else runs on both. * **Web-standard APIs only** in core packages: `fetch`, `Request`, `Response`, Web Crypto, streams. Node APIs live only in `packages/platform/src/node` and `apps/inbox/src/node.ts`. * **Zod 4 schemas are the single source of truth** for types, validation, the JSON Schema of MCP tools and the OpenAPI document. * **No interactive database transactions.** D1 is batch-only; use `batch()`, unique constraints and compare-and-set. `db.transaction()` is banned by lint. * **`pnpm check` (Biome) and `pnpm typecheck` must be clean.** * Every protocol is a thin adapter over one capability set; adapters pin their spec version and ship fixtures and a conformance suite, and can be disabled per instance. ## Layout [Section titled “Layout”](#layout)
```plaintext
apps/inbox the product: a Hono server that runs on Workers and on Node
apps/site this website: Astro, Starlight for the docs
packages/core domain model, state machines, rules, jobs, receipts
packages/platform the runtime interfaces (Db, Blob, Jobs, MailIn, MailOut) and their adapters
packages/adapters the doors: REST + OpenAPI, MCP, email, OAuth, sessions
packages/spec manifest, receipt and network formats with test vectors
packages/sdk typed client for the public and owner APIs
packages/ui design tokens, glass utilities and the kit page
docs/ the plan and the ADRs
```
## Pull requests [Section titled “Pull requests”](#pull-requests) Small, with a test on both runtimes when behaviour changes. If a change needs a decision, write the ADR first: a page with the decision, the why and the consequences, numbered after the last one.
# Email
> Your inbox's own address, your existing address forwarded to it, replies that land in the right conversation, answering alerts by email, and sending from your own domain.
Customers write to the address they already know. You forward it to your inbox’s own address, and every conversation carries on there: their replies come back to the same request, and you can answer from your mailbox or from the inbox. Settings → **Email**. ## Your inbox’s address [Section titled “Your inbox’s address”](#your-inboxs-address) Every inbox has an address of its own. On a hosted inbox it is set for you. On your own server it is the address your mail routing delivers to the inbox (see [Your own server](#your-own-server)). Each conversation gets an address of its own too: your inbox’s, with a code after a `+`. Every email to a customer carries it as the address to reply to, so their answer lands on their request even when their mail program drops the rest. The code is random and is never the request’s number. ## Forward your existing address [Section titled “Forward your existing address”](#forward-your-existing-address) Add the address your customers use and where it lives — Gmail, Google Workspace, Outlook.com, Microsoft 365, iCloud Mail, Yahoo Mail, Proton Mail, Fastmail, Zoho Mail, IONOS, GoDaddy, Hostinger, OVH, cPanel or another provider. The page reads the likely one from the address, and shows that provider’s steps with your inbox’s address already in them. Forward only the address customers write to. If it shares a mailbox with your own mail, forward just its mail with a filter or a rule: the steps say how. Newsletters and notices that reach the inbox anyway are kept under Notices, never answered. * **Confirmations.** Gmail and Google Workspace email a code and a link before they forward anything, and Zoho a code. They show on the page, named by the provider that sent them: open the link (it only ever opens `mail-settings.google.com`) or copy the code into your provider, then press *Check forwarding*. Only a confirmation from the provider’s own sender is shown this way, never one that merely looks like it. The inbox never opens or types anything for you. * **Check forwarding.** Sends a short email to your address. If forwarding works it comes straight back, and the page says *Forwarding works*. It is sent to your address with a `+` code first: when that comes back too, your customers’ replies can go to your own address, with the code, and still find their conversation. A few emails can still be refused on the way: those whose senders sign nothing, when your forward does not rewrite the sender. Gmail and Microsoft 365 forwards keep them working. Checks are limited to a few a day. * **Microsoft 365** holds back forwarding outside your company until your admin allows it, and a shared mailbox is forwarded by your admin. The steps say where. Forwarding sends new mail from the moment it is on. Your mailbox can keep its own copy. ## Answer by email [Section titled “Answer by email”](#answer-by-email) Every alert about a new request or message quotes what the customer wrote. Reply to it from your mailbox, and your answer goes to the customer from your business, in the same conversation. It works only from the address you sign in with or are alerted at, only for that alert, and never for an automatic reply such as an out-of-office. Files attached to your answer stay on the request: they are not sent on. Your own address is never shown to a customer. ## Who replies go out from [Section titled “Who replies go out from”](#who-replies-go-out-from) * **Hosted:** in your business’s name, from the inbox’s sending address. When your inbox has an address of its own, customers’ replies come back to the conversation. Until then, set *Customers reply to* in Settings to a mailbox at your own domain (such as `hello@yourshop.com`) and their replies go there; without one, emails to customers don’t ask for a reply. * **Hosted, from your own address** (when your host offers it): once an address of yours forwards here and passed its check, choose it under *Replies go out from*. The page shows the DNS records to add — three that sign your email and two for a subdomain that takes returned email; your domain’s own SPF record is left as it is, and none of them changes where your email goes — with your DNS provider’s steps, *Copy all* and *Send to my web person*, and checks them. Email goes from your address once they check out, and from the inbox’s sending address until then. * **Your own server:** from `MAIL_FROM`, through the mail service you set up. That service’s own records sign for your domain. ## What the inbox does with what arrives [Section titled “What the inbox does with what arrives”](#what-the-inbox-does-with-what-arrives) Every email that arrives is kept. Nothing is thrown away. * **Notices:** emails from systems, like receipts, account and newsletter mail. Nobody answers them. A newsletter, a shop’s or a bank’s notice, a case update, anything from a `noreply` address, that answers no conversation, is kept under Notices: nobody is alerted, no rule runs on it, and no webhook, key or AI reads it. Move one to the inbox to keep it as a conversation (still yours alone), or delete it. One that answers a conversation lands on it, as any reply does. A sign-in code or link alerts you as it comes, when its sender’s domain is proven; one that is not proven is a notice. A website form’s email, sent from a `noreply` address with answers going to the person who filled it in, is a message like any other. * **Its own email coming back** — an alert sent to an address that forwards here — is recognised and kept under Notices, never answered, so nothing loops. * **Out-of-office and returned emails** never start a conversation and are never answered: one that answers a conversation is kept on it as a note, any other under Notices. A returned email marks the email it returns as not delivered, and the request asks for you. * **Spam:** an email a server on its way marked as spam is put aside, unanswered, unless it comes from someone you know, when it is only labelled. It waits under Spam, where you can take it out. The [spam filter](/docs/spam/) says what else it keeps off your desk. * **Attachments** are kept for you to download, or delete: up to 10 files and 15 MB an email, 10 MB a file, and a limit for what one sender leaves in a day. A file past them is named on the email, which is kept. Files that run, such as `.exe` or macro documents, are named but never kept. The files of an email put aside as spam are named, never kept. They are erased with the customer. * **Answers by email that are not sent** — from another address, one its mail server could not show was yours, one with no words, or one that still has your own address in it — are kept on the request as a note for you, with what they had attached, saying why. Answer from the inbox instead. * **A flood:** past 200 notices a day from one sender’s domain, each is still kept, with only the spam filter’s surest checks, and you get one email that day saying so. *Delete all from this sender*, on a notice, clears that sender’s notices at once. * **Too large:** an email over 25 MB is the only one not kept. It is refused on its way in, so its sender gets it back and knows. ## Your own server [Section titled “Your own server”](#your-own-server) Write the inbox’s address in Settings → Email. Mail reaches the inbox one of two ways: * **On Cloudflare:** in Email Routing, route the address to the Worker and turn on **Subaddressing** (Email Routing → Settings), so its conversation addresses (`inbox+t…@`) reach it too. A catch-all to the Worker also works. * **Anywhere:** a forwarder or a provider’s inbound webhook posts the raw email to `POST /v1/email/inbound`, with the secret from Settings (`email.inboundSecret`) in `X-Inbox-Email-Secret`. Conversation addresses are plus addresses of the inbox’s, so route the whole address, `+` included. Pulling a mailbox over IMAP is not built yet. The decisions behind this page are in [ADR-026](https://github.com/surfingdogai/inbox/blob/main/docs/adr/026-email-channel.md).
# Product feeds
> Point your Inbox at the product feed your shop already publishes, and agents can answer what you sell and what it costs from the same numbers your customers see.
An agent asked to buy something from you needs to know two things: whether you have it, and what it costs. A **product feed** is the cheapest way to tell it, because your shop almost certainly publishes one already. Nearly every platform emits a feed so that Google Shopping can read it — WooCommerce®, Wix, PrestaShop, BigCommerce, Squarespace and Shopify all do. It is a public URL. There is no password to hand over, no app to install, no OAuth screen and nothing to review. You paste the URL, and your catalogue fills itself. WooCommerce and its associated designs are trademarks of Automattic Inc. ## Connect one [Section titled “Connect one”](#connect-one) Settings → Integrations → **Connect a feed**. Paste the URL your platform calls a product feed or a Google Shopping feed, and the first import starts at once. After that it re-imports every six hours, and **Import now** runs one immediately. Or over the API, with an owner key:
```bash
curl -X POST https:///v1/owner/feeds \
-H "authorization: Bearer sdi_own_…" -H 'content-type: application/json' \
-d '{ "url": "https://yourshop.example/feed.xml" }'
```
`GET /v1/owner/feeds` lists them with what each one last did, `POST /v1/owner/feeds/{id}/import` runs one now, and `DELETE /v1/owner/feeds/{id}` disconnects. ## What it reads [Section titled “What it reads”](#what-it-reads) Two formats, which between them cover what the platforms actually emit. **A spreadsheet export**, separated by commas, semicolons, tabs or pipes. The separator is detected from your header row, so a European export full of semicolons and decimal commas works without being told. Quoted fields, embedded commas, embedded newlines and a byte order mark are all handled. **Google Merchant XML**, the feed built for Google Shopping, including the `g:` namespace, CDATA and entities. Columns are matched by name, ignoring case, spaces and punctuation, so `Product Name`, `product_name` and `productname` are one column. It also reads the Portuguese, Spanish, German and French spellings of the common ones. | What we take | Columns we recognise | | ------------------- | ----------------------------------------------------------------------------------------------------------- | | Identity | `id`, `sku`, `mpn`, `gtin`, `ean`, `reference`, `referencia`, `artikelnummer` | | Name | `title`, `name`, `product_name`, `nome`, `nombre`, `titel`, `titre` | | Description | `description`, `summary`, `descricao`, `descripcion` | | Price | `price`, `regular_price`, `unit_price`, `preco`, `precio`, `preis`, `prix` | | Sale | `sale_price`, with `sale_price_effective_date`, or `Date sale price starts` and `Date sale price ends` | | Stock | `quantity`, `stock`, `inventory`, `qty`, `estoque` | | Availability | `availability`, `in_stock`, `stock_status` | | Links | `link`, `image_link` (kept only when they are `https` on a public host) | | Barcode | `gtin`, `ean`, `upc`, `barcode`, `isbn`, `jan`, `ean13`, `gtin8` to `gtin14` | | Brand | `brand`, `marca`, `marke`, `marque`, `vendor`, `manufacturer` | | Part number | `mpn`, `manufacturer_part_number`, `part_number` | | Condition | `condition` (`new`, `used`, `refurbished`, and their Portuguese, Spanish, German, French and Italian words) | | Category, as a hint | `google_product_category`, `product_category`, `product_type`, `category` | The sale price is the price while the sale runs, and the regular price before and after it, the same in both formats. A sale with no dates runs. Dates written without an offset (`2026-11-27/2026-11-30`) are read on your business’s clock, a date alone meaning that whole day; a sale whose dates cannot be read is not taken to be on. A sale starts and ends here at the moment its dates give: the feed is read again then, and the price changes even if the feed cannot be read at that moment. A product whose only price is a sale that is not on is kept off sale. A `cost` column is never read as a price: it is what you pay, not what you sell at. A product with no price is kept off sale until the feed gives it one. ### Barcodes [Section titled “Barcodes”](#barcodes) A barcode is read beside the identity, not instead of it: a sheet whose only code is its `ean` still has that code as the product’s SKU, so an agent that orders by it is served. It is checked the way the networks check it: spaces and hyphens removed, a UPC whose leading 0 a spreadsheet dropped put right, an ISBN-10 read as its book barcode (never from a UPC column), and the last digit checked against the others. One that does not add up, a number a spreadsheet turned into `4.00638E+12` or `3017620422003.0`, a shop’s own in-store code or a coupon is kept for you to read beside the product, and the product is listed without a barcode until it is fixed. A row that leaves the barcode, the brand or the part number out has not lost them: what an earlier read gave is kept. The category is a hint for later and never shown. Google’s `identifier_exists` is read, and saying a product has no barcode stays yours to do, in the app. ### Prices [Section titled “Prices”](#prices) A price cell is read the way a person reads it. `12.99 EUR`, `EUR 12.99`, `€12,99`, `£9`, `1.234,56 €` and `R$ 49,90` all land on the right number and the right currency. Words around the number are ignored, so `12,99 € inkl. MwSt.` is twelve ninety-nine and not one thousand two hundred. The decimal separator is decided by what follows it: two digits or fewer make it a decimal point, three make it a thousands mark. So `1.234,56` is one thousand two hundred and thirty-four, and `1.234` is one thousand two hundred and thirty-four as well, which is what a shop means by it. A price is in the currency it names, by its ISO code (`45.00 QAR`, `KES 1,500.00`, any code in ISO 4217) or by its sign. A three-letter word that is not a currency code is ignored rather than believed, so `12.99 incl VAT` is not priced in VAT. Nor are the codes that are also words a price carries in capitals (`ALL`, `TOP`, `CUP`, `SOS`, `BAM`): if you price in one, set it as your currency and write the bare number. The signs read are `€`, `£`, `$`, `¥`, `kr`, `zł`, `Kč`, `R$`, `KSh`, the dollar signs with their country’s letters (`US$`, `A$`, `AU$`, `C$`, `CA$`, `NZ$`, `HK$`, `S$`, `MX$`, `NT$`, `RD$`, `TT$`, `J$`), `CN¥` and `JP¥`, `E£`, the signs of their own currency (`₹`, `₪`, `₺`, `₽`, `₴`, `฿`, `₱`, `₦`, `₡`, `₵`, `₸`, `₼`, `₾`, `₭`, `₮`, `৳`, `៛`, `֏`, `؋`, `₩`, `₫`, `₲`), `円`, `元` (and `圆`, `圓`), `원`, the full-width `$`, `¥` and `₩`, and the Arabic abbreviations of the dirham, riyal, rial, dinar and pound (`د.إ`, `ر.س`, `ر.ق`, `ر.ع.`, `د.ك`, `د.ب`, `د.ا` or `د.أ`, `د.ع`, `د.ت`, `د.ج`, `د.م.`, `ل.د`, `ج.م`, `ل.ل`, `ل.س`, `ر.ي`; `ر.ع.` and `د.م.` with or without their last full stop). A sign several currencies write is your currency when yours is one of them: `$25.00` is Australian dollars for a shop in Australian dollars, and `199,00 kr` is Norwegian crowns for one in crowns. Otherwise `$` is US dollars, `kr` Swedish crowns, `£` pounds sterling, `¥` yen, `元` yuan and `C$` Canadian dollars. The rupee sign `₨` and the rial sign `﷼` are read only in your own currency, and any other currency sign, such as `¢`, is not read as a price. If a price states no currency, your business currency is used, or the one you gave the feed. Every price here is counted in its currency’s own minor unit, as ISO 4217 counts it, by its code, its sign or as your feed’s currency: cents for euros, whole yen (`4980 JPY`, `¥4,980` and `4,980円` are all ¥4,980), whole won (`₩12,000`), dong and guaraní, and thousandths for Kuwaiti and Bahraini dinars and Omani rials (`12.500 KWD` and `12.500 د.ك` are twelve and a half dinars). Never as euros, nor a hundred or a thousand times the amount. A code that is no currency a price is set in, a fund code such as `CLF` or a metal such as `XAU`, is not read as a price, and the product is kept off sale. A price of nothing or less, such as a placeholder `0.00` or a refund’s `-5.00`, is no price to sell at either: the product is kept off sale, with the price it last had, until the feed gives it a price again. A price left empty is different: the product keeps the price it last had, and stays on sale. ## What it never does [Section titled “What it never does”](#what-it-never-does) **It never touches a product you typed in.** A feed owns only the products it created. Anything you added by hand in Settings is invisible to it. **It never deletes a product.** When something leaves the feed it is taken off sale and kept, because an order may point at it and a deleted product takes that order’s history with it. If it reappears in the feed, it comes back. **It never empties your shop on a short read.** A feed is read up to 5,000 products. If it hits that limit, nothing is deactivated, because a truncated read is not evidence that anything is gone. **It never takes a sku another product owns.** If a product you typed in already holds that code, the imported one is stored without it rather than stealing it. ## Disconnecting, and connecting again [Section titled “Disconnecting, and connecting again”](#disconnecting-and-connecting-again) Disconnecting takes the feed’s products off sale and keeps them. Connecting the same URL again adopts them back rather than importing a second copy of everything: a feed’s products are identified by the feed’s address, not by the connection, so the identity survives. ## When something is wrong [Section titled “When something is wrong”](#when-something-is-wrong) A feed that cannot be read says so on its row in Settings, with the reason. A row that cannot be made into a product is skipped and counted rather than guessed at, and the reason is one of: no identifier, no name, or an identifier already used earlier in the same feed. If a row has an id column and that cell is empty, the row is skipped rather than falling back to the product’s name. A name is not an identity: the day someone fixes a typo in it, a name-keyed product becomes a second product. ## Where a feed is fetched from [Section titled “Where a feed is fetched from”](#where-a-feed-is-fetched-from) Only a public `https` address. An `http` URL is upgraded. Addresses on the machine or the network the Inbox runs on are refused, and a redirect is followed only as far as another public address, because a public URL that redirects to an internal one is how a server gets talked into fetching something it should not. ## What it does not do yet [Section titled “What it does not do yet”](#what-it-does-not-do-yet) A feed is one way. It tells your Inbox what you sell; it does not write anything back to your shop, and it does not carry orders in either direction. A platform connector that reconciles both ways is the next step. Ask for it on [the requests board](/requests/).
# Get your agent inbox
> Set up a hosted inbox from your check result, then connect it to your own domain with one DNS record. The record, and the steps for each DNS provider.
A hosted inbox is Surfing Dog Inbox run by us, at an address on your own domain. It takes two steps: set it up from your website’s check result, then add one DNS record. It is free; Pro is $10, €10 or £9 a month, taxes included, if you want it ([terms](/hosted/terms/)). ## Before you start [Section titled “Before you start”](#before-you-start) * **A domain of your own,** such as `yourbusiness.com`. An address on a platform’s domain cannot carry your record. * **A way to add a DNS record** at that domain: your login at the company that runs your DNS, or someone who has one (your web person, your IT company). * Health and medical practices cannot join yet. ## 1. Set it up [Section titled “1. Set it up”](#1-set-it-up) 1. Check your website at [surfingdog.ai/check](/check). On the result, choose **Get your agent inbox**. If your website has not been checked yet, the card offers to check it, usually within a minute; if its check is old, it offers to check it again. 2. The card shows your business as the check read it, and what agents will be able to do. Type your email address and choose **Send my code**. New requests go to this address; customers never see it. 3. Enter the six-digit code from the email, or open the email’s **Confirm** button. There is nothing else to fill in. Your inbox starts from what the check read on your website (your kind of business, your hours, your services), or a default where it read nothing, and all of it is yours to change in Settings. Your inbox is now set up at a private address of ours, a generated name like `quiet-otter-7k2m.surfingdog.ai`. Nobody but you can open it there. Agents cannot reach it, and nothing about it is listed or indexed. We test it as an agent would, and show you what passed, such as **Tested as an agent: book ✓ quote ✓ message ✓**. ## 2. Add one DNS record [Section titled “2. Add one DNS record”](#2-add-one-dns-record) The Connect screen shows your record. It looks like this: | Type | Name | Target | TTL | | ----- | ------- | ------------------------------- | -------------- | | CNAME | `inbox` | `k7m2qxpa.hosted.surfingdog.ai` | leave as it is | The target is **unique to your inbox**: copy yours from the Connect screen, never from this page or from another business. Your inbox’s hostname is then `inbox.yourbusiness.com`. You can choose another name than `inbox` on the Connect screen until the record is seen. Nothing else on your website or your email changes: the record adds one new name. The Connect screen tells you who seems to run your DNS, from your domain’s name servers. It shows the steps for that provider where we have checked them, and steps that work anywhere otherwise. If it guessed wrong, choose **Not right? Pick yours**. In the Name field type just `inbox`: most panels add your domain themselves. If the panel shows your domain after the field, do not type it again. ### Cloudflare [Section titled “Cloudflare”](#cloudflare) 1. **DNS → Records → Add record.** 2. Type **CNAME**, Name `inbox`, Target your target. 3. **Proxy status: DNS only** (the grey cloud). A proxied record hides the CNAME, and the connection cannot finish. 4. **Save.** ### GoDaddy [Section titled “GoDaddy”](#godaddy) 1. **Domain Portfolio →** your domain **→ DNS → Add New Record.** 2. Type **CNAME**, Name `inbox`, Value your target, TTL as it is. 3. **Save.** ### Namecheap [Section titled “Namecheap”](#namecheap) 1. **Domain List → Manage →** **Advanced DNS → Add New Record → CNAME Record.** 2. Host `inbox`, Value your target, TTL **Automatic.** 3. **Save All Changes** (the tick). ### Squarespace Domains [Section titled “Squarespace Domains”](#squarespace-domains) This includes domains that moved from Google Domains. 1. **Domains →** your domain **→ DNS.** 2. Under **Custom records**, add a record: Type **CNAME**, Name `inbox`, Data your target. 3. **Save.** ### Wix [Section titled “Wix”](#wix) 1. **Domains →** the **Domain Actions** menu beside your domain **→ Manage DNS Records.** 2. Under **CNAME**, choose **Add Record.** 3. Host Name `inbox`, Value your target. 4. **Save**, then **Save Changes** in the pop-up. ### IONOS [Section titled “IONOS”](#ionos) 1. **Domains & SSL →** the gear beside your domain **→ DNS → Add record → CNAME.** 2. Hostname `inbox`, Point to your target. 3. **Save.** If IONOS asks for the subdomain before the record, create `inbox` under **Manage Subdomains**, then open its DNS. ### Google Cloud DNS [Section titled “Google Cloud DNS”](#google-cloud-dns) 1. Open your zone, then **Add standard.** 2. DNS name `inbox`, Resource record type **CNAME**, Canonical name your target **with a final dot** (`k7m2qxpa.hosted.surfingdog.ai.`). 3. **Create.** ### Shopify [Section titled “Shopify”](#shopify) 1. **Settings → Domains →** your domain **→ Domain settings → Edit DNS settings → Add custom record.** 2. Type **CNAME**, Name `inbox`, Points to your target. 3. **Confirm.** ### Any other provider [Section titled “Any other provider”](#any-other-provider) Look for the DNS settings of your domain and add a record of type **CNAME**. Providers name the fields differently: * **Name** may be called Host, Host name, Alias or Record name. Enter `inbox`. When the panel shows your domain after the field, it adds it for you: do not type it again. * **Target** may be called Value, Points to, Data, Canonical name or Destination. Enter your target exactly. * **TTL:** leave it as it is. If the panel offers a proxy or a “flattening” switch for the record, leave it off. ## Someone else runs your DNS [Section titled “Someone else runs your DNS”](#someone-else-runs-your-dns) Choose **Someone else runs it** on the Connect screen. It opens an email in your own mail app, addressed to nobody, for you to send to whoever runs your domain; we never send it. It reads:
```text
Subject: One DNS record for yourbusiness.com
Hello,
Could you add this DNS record for yourbusiness.com?
Type: CNAME
Name: inbox
Target: k7m2qxpa.hosted.surfingdog.ai
(If the DNS is on Cloudflare: Proxy status DNS only.)
It connects our agent inbox at inbox.yourbusiness.com. Nothing else on the website changes.
You can see when it works here: https://app.surfingdog.ai/connect/…
Thank you
```
The link at the end shows the record and the status, and nothing about your business or your email. It stops working when your inbox goes live, after 30 days, or when you make a new one. ## What the status means [Section titled “What the status means”](#what-the-status-means) The Connect screen checks for your record every minute while it is open, and less often after that. **Check now** looks at once. * **Waiting for the record.** Most providers show a new record within minutes; some take a few hours. * **Record found. Connecting inbox.yourbusiness.com…** We saw your record and asked for a certificate for your hostname. * **Issuing the certificate for inbox.yourbusiness.com.** This usually takes a few minutes. * **Testing as an agent…** We call your inbox from the outside, as an agent would. * **inbox.yourbusiness.com is live.** Agents can reach it now. We email you once to say so. ## If something is wrong [Section titled “If something is wrong”](#if-something-is-wrong) * **“… is in use already.”** The name you chose already has records, so it may be in use. Choose another name, such as `book`. Remove those records if you know nothing uses them. * **“… points to something else. It should point to your target.”** The Target field has a typo or an old value. * **“… points to another inbox’s address.”** The target belongs to someone else’s inbox. Copy yours from the Connect screen. * **“The record is proxied by Cloudflare.”** Set its Proxy status to **DNS only**. If it is DNS only already, turn off CNAME flattening for all records in DNS Settings. * **“The record was saved as inbox.yourbusiness.com.yourbusiness.com.”** Your panel adds your domain itself: in the Name field type just `inbox`. * **“Your panel added yourbusiness.com to the end of the target.”** Type the target with a dot at the end, such as `k7m2qxpa.hosted.surfingdog.ai.` * **“… don’t all show the record yet.”** Your domain’s name servers disagree for a moment while the record spreads. It usually settles within minutes. * **“… is answered by … name servers, not yourbusiness.com’s own.”** The name is delegated to other name servers. Add the record where your domain’s own DNS is managed. * **“Your provider turned the record into an address.”** Some providers flatten a CNAME into an address. Keep it a CNAME for this name. * **“Still no record after two days.”** Check that the Name is exactly the name you chose and the Target exactly your target. * **“… limits who may issue its certificates (a CAA record).”** Your domain allows some certificate authorities and not others. Add the records the Connect screen shows, and the certificate can be issued. * **“We can’t connect this name.”** Write to us and we will look. * **“Still connecting. We’re on it.”** A problem on our side; there is nothing for you to do. ## Once it is live [Section titled “Once it is live”](#once-it-is-live) * **Open your inbox** at `https://inbox.yourbusiness.com`. Requests from agents and people arrive there; your rules answer the routine ones. * **Connect your AI** to `https://inbox.yourbusiness.com/mcp/owner` ([how](/docs/connect-your-ai/)). * **You are on the network.** Your inbox joins network.surfingdog.ai the day it goes live. It lists you where AI agents search, and keeps your track record: each booking and order, and whether it was kept. Switch it off in Settings → Networks if you would rather not, even before it goes live. * **Keep the record.** If it is removed or changed, agents can’t reach your inbox there. Put it back within 72 hours and nothing else changes; after that your inbox stops taking requests from agents until the record is back. Meanwhile you can still open your inbox from your account. ## Questions [Section titled “Questions”](#questions) **Why my own domain?** An agent deciding whether to trust an address looks at its domain. Yours already belongs to your business, and adding a record there shows the inbox is yours. No other proof is needed. **Does anything else change on my website or my email?** No. The record adds one new name, `inbox.yourbusiness.com`, and touches nothing else. **What does it cost?** Nothing. Pro is optional: $10, €10 or £9 a month, taxes included, for the three AI spam filters ([spam](/docs/spam/)). The $10 supports the open-source inbox, the open network, and keeping access to AI agents from being locked in a connector store. **Can I leave?** At any time. Download everything as one file that the open-source inbox opens, with the key your receipts are signed with if you choose, so an inbox you run yourself signs as this one did. Your track record stays with your address, `inbox.yourbusiness.com`, wherever you point it. Then close your inbox, or point `inbox.yourbusiness.com` at an inbox you run yourself ([self-hosted vs hosted](/docs/self-hosted-vs-hosted/)).
# Integrations
> What your Inbox connects to today, what waits on another company, and the rules every connection keeps. Your own accounts and keys, paid into your own account, nothing said before it works.
Every one in a table, with what is coming: [Integrations](/integrations/). Settings → **Integrations**. Calendars, payments, shops and checkouts, property systems, job tools, and ordering and tills, each a card. A card says **Connect** only when your Inbox can connect it; one that waits on something says **Soon** or **Needs setup**, with nothing to press. One that lets only approved partners connect (Mews, apaleo, Cloudbeds, Smoobu, Toast, Lightspeed, Clover, Deliverect, Jobber) says it is **waiting for the partner**. A connector built from its platform’s documentation and checked against a stand-in, but not yet against a live account of that platform, says so on its card, in the words used for it [below](#in-this-release). The setup wizard’s “Bring them from what you already use” lists the same systems with the same words: shops and tills for products and menus, property systems for rooms. Beside services it lists calendars and booking tools as keeping their times in step, since none of them brings services. On a server without `INBOX_SECRET_KEY`, each says it needs setup on this server. ## In this release [Section titled “In this release”](#in-this-release) | Card | Connects with | Does | | ----------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | **Calendar**: iCloud, Fastmail, Nextcloud, any CalDAV | An app password | Busy times in, bookings out ([Calendars](/docs/calendars/)) | | **Calendar**: Google | Sign in with Google, on a server with a Google client | Busy times in, bookings out | | **Calendar**: Outlook, or any calendar’s link | The link the calendar publishes | Busy times only | | **Cal.com** | An API key | Busy times in, bookings out | | **Calendly** | A token, or Sign in with Calendly on a server with a Calendly app | Busy times in; bookings out on its paid plans ([Calendly](/docs/connectors/calendly/)) | | **Share your bookings** | A private link your Inbox makes | Your bookings, for another site’s calendar sync ([Share your bookings](/docs/connectors/calendar-link/)) | | **Stripe**, **Mollie** | A key of your own account | Orders paid into your account ([Payments](/docs/payments/)) | | **WooCommerce®** | One press on your shop’s Approve page, or its keys | Catalogue in, orders paid at your checkout ([WooCommerce](/docs/connectors/woocommerce/)) | | **Shopify** | An app you make for your own shop | Catalogue in, orders paid at your checkout ([Shopify](/docs/connectors/shopify/)) | | **Square** | An access token, or Sign in with Square on a server with a Square app | Bookings, catalogue, and orders paid at your Square checkout ([Square](/docs/connectors/square/)) | | **Property system**: Beds24, Hostaway | An invite code; an account ID and API key | What is free in, each stay written there first ([Property systems](/docs/property-systems/)) | | **Ordering and till**: GloriaFood, Flipdish | Keys from your own account | Your menu in, to start from ([Which tills](/docs/ordering/#which-tills)) | | **Job tool**: ServiceM8 | Your API key | Requests you take on, as jobs ([Job tools](/docs/job-tools/)) | | Product feeds | The feed’s address | Your catalogue, from the feed Google reads ([Feeds](/docs/feeds/)) | | Webhooks, keys | An endpoint or a key | Zapier, Make, n8n or your own code ([Webhooks](/docs/webhooks/)) | WooCommerce and its associated designs are trademarks of Automattic Inc. The calendar, shop, kitchen, stay, trade and payment connections (iCloud and the other CalDAV calendars, Google, Outlook, Cal.com, Calendly, WooCommerce, Shopify, Square, Beds24, Hostaway, GloriaFood, Flipdish, ServiceM8, Stripe and Mollie) are built from each one’s documentation and tested against a stand-in; not yet verified on a live account. Stripe, WooCommerce and CalDAV, the way the other CalDAV calendars connect, have since run end to end against the real platform, on 1 November 2026: Stripe in its test mode, and a WooCommerce store and a CalDAV server run on our own machine; none yet on a live account. Each one’s page says what ran: [Stripe](/docs/payments/), [WooCommerce](/docs/connectors/woocommerce/), [CalDAV](/docs/calendars/#icloud-fastmail-nextcloud-and-other-caldav-calendars). On a hosted inbox, Google says **Soon** until Google has checked our sign-in, and Sign in with Calendly or Square says **Soon** until our app is there; their tokens work meanwhile. Already take bookings or orders on another site? Settings → General sends agents there for that: [Booked or ordered elsewhere](/docs/connectors/elsewhere/). ## Waiting on another company [Section titled “Waiting on another company”](#waiting-on-another-company) Built, and switched off until what each needs is in place: * **Mews**, **apaleo**, **Cloudbeds** and **Smoobu** ([Property systems](/docs/property-systems/#not-switched-on-yet)). * **Toast**, **Lightspeed Restaurant**, **Clover** and **Deliverect**, once each one’s partner app is approved ([Which tills](/docs/ordering/#which-tills)). * **Jobber**, through its reviewed app; **Housecall Pro** and **Fergus**, once the server has cleared their API terms ([Job tools](/docs/job-tools/#which-tools)). ## The rules every connection keeps [Section titled “The rules every connection keeps”](#the-rules-every-connection-keeps) * **Your own accounts, your own keys.** You paste a key you made, sign in on the platform’s own page, or approve a page on your own store. Keys are encrypted before they are stored, and never shown again, to you or to your AI. * **You are the merchant.** Money goes to your own Stripe, Mollie, shop checkout or till. Your Inbox never takes card details, never adds a fee, and never takes a share. * **Nobody pays more than they accepted.** If your shop adds tax or delivery on top, the link to pay is held, and you can propose the shop’s total to the customer, who accepts it or not. * **Your customer hears from you.** What a customer sees is in your name. Where a platform lets us, it does not email a customer your Inbox has already told. * **A webhook is a nudge.** Its signature is checked, and what it says is read back from the platform with your key. Only ids are kept, never what the platform sent about your customer. Your Inbox also looks for itself every few minutes. * **In person.** Connecting, changing and disconnecting are yours, signed in. Your AI and the keys you hand to other systems can list and sync, nothing more.
# Job tools
> Where a trade runs its jobs. Which job tools your Inbox connects to, what a connection does, and what each needs.
A job tool is where a trade keeps its jobs, its quotes and its schedule. Connected, the requests you take on go into it, with their photos, and the visits scheduled in it take those times off what your Inbox offers for site visits. Settings → Integrations → **Job tool**. Which requests go in: Settings → [Work](/docs/trades/#your-job-tool). ## Which tools [Section titled “Which tools”](#which-tools) ServiceM8 connects with your own key today: built from ServiceM8’s documentation and tested against a stand-in; not yet verified on a live account. Housecall Pro and Fergus connect with yours too, once the server you are on has cleared their API terms for this use; until then they show **Needs setup**. Jobber, and ServiceM8’s one-click sign-in, show **Needs setup** until their partner app is on the server. Nothing to press shows a button that goes nowhere. | Tool | Connects with | What a request becomes | Its developer documentation | | ------------- | ---------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------- | | ServiceM8 | Your API key (Settings → API Keys) | A job in Quote status, with a client, a contact and the photos in the job diary | | | Housecall Pro | Your API key (MAX plan) | A lead in your Job inbox (turn on its API Leads channel) | | | Fergus | A personal access token (admins; lasts a year) | An enquiry, with the photos and plans | | | Jobber | A Jobber app reviewed by Jobber, on the server | A request on your request form | | On your own server: `JOB_TOOLS_CLEARED=housecallpro,fergus` once you have read their terms; `JOBBER_CLIENT_ID`, `JOBBER_CLIENT_SECRET` and `JOBBER_API_VERSION` (the version your Jobber app was checked against) for Jobber, whose webhooks go to `/v1/hooks/jobber/app`; `SERVICEM8_CLIENT_ID` and `SERVICEM8_CLIENT_SECRET` for ServiceM8’s sign-in. ## What a connection does [Section titled “What a connection does”](#what-a-connection-does) * **Only the requests you choose.** By default, the ones you take on: when you quote one, or press **Send to …** on it. Never a test request. * **Once.** A request is never in your tool twice: a second press, a retry or a lost answer finds the one already there. * **Only what the job needs.** Who to answer, the site and how to find the way, what it is, when suits, the customer’s words, and the photos where the tool takes files (ServiceM8, Fergus; a Housecall Pro lead links back to the request instead). Nothing goes to anyone else. * **Your schedule, read.** Visits scheduled in your tool are busy times, read every few minutes; a change your tool announces is read at once. * **Your hours, to start from.** ServiceM8 keeps your opening hours: Settings → Work → **Copy opening hours from ServiceM8** shows them beside yours, and **Use these hours** makes them your Inbox’s. Nothing changes until you press it, and hours kept in another time zone than your Inbox’s are shown, not copied. * **One job each.** With **Every request**, each request becomes a ServiceM8 job, and ServiceM8 may charge for the jobs it makes. * **Your token, watched.** A Fergus token lasts a year; Settings → Work says when yours runs out, two weeks before. ## Building one [Section titled “Building one”](#building-one) Every job tool is built from its published reference and tested against a stand-in of it (`packages/adapters/test/fakes/work.ts`); none is yet verified on a live account.
# Manifest
> The discovery document an instance publishes at /.well-known/agent-inbox.json.
An instance publishes one small document at `/.well-known/agent-inbox.json`. It says what the instance accepts and where each protocol endpoint lives, so an agent can pick the door it prefers without fetching every card. The path is one constant, `MANIFEST_PATH`, in the MIT `@surfingdog/spec` package, and the name follows the `agent-card.json` pattern; a provisional IANA registration under RFC 8615 is planned once the spec page is stable ([ADR-002](https://github.com/surfingdogai/inbox/blob/main/docs/adr/002-well-known-name.md)). The format, field by field with what changed when, is the versioned spec: [Agent inbox manifest](/spec/manifest/). [Check yours](/spec/check/). ## Shape [Section titled “Shape”](#shape)
```json
{
"spec": "surfingdog-inbox/0",
"instance": "https://inbox.example",
"profile": {
"name": "Oficina Maré",
"description": "Bicycle workshop",
"categories": ["bicycle-repair"],
"languages": ["pt", "en"],
"address": { "addressLocality": "Ericeira", "addressCountry": "PT" },
"geo": { "latitude": 38.96, "longitude": -9.42 },
"url": "https://oficinamare.pt",
"hours": {
"timezone": "Europe/Lisbon",
"weekly": { "mon": [["09:00", "13:00"], ["14:00", "18:00"]], "sat": [["10:00", "13:00"]] },
"closures": [{ "from": "2026-12-24", "to": "2026-12-26" }]
},
"services": [
{ "name": "Tune-up", "type": "booking" },
{ "name": "Wheel build", "type": "quote_request" }
]
},
"item_types": ["message", "quote_request", "booking", "refund"],
"protocols": {
"openapi": "https://inbox.example/openapi.json",
"rest": "https://inbox.example/v1",
"mcp": "https://inbox.example/mcp"
},
"agent_policy": {
"tiers": ["anonymous", "signed_agent", "verified_principal", "reputed_principal"],
"signatures": ["sdi-agent/1", "web-bot-auth"],
"passes": true,
"networks": ["https://network.surfingdog.ai"],
"guide": "https://surfingdog.ai/for-agents.md"
},
"receipt_keys": { "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": "…", "kid": "…" }] },
"review_services": []
}
```
| Field | Meaning | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `spec` | The format version. Always `surfingdog-inbox/0` today. | | `instance` | The instance’s origin. | | `profile` | Public profile data the directory may index: the business’s name and languages; the description, categories, postal address, coordinates and website the owner filled in under Settings (each only when it is not empty); its weekly hours with the closures that have not ended; up to thirty active services, by name and how each is taken; and how customers can pay (`payments`: the ways the owner ticked in Settings → Payments, as tokens of the [payments vocabulary](https://github.com/surfingdogai/inbox/blob/main/packages/spec/vocab/payments.json) such as `card`, `apple_pay` or `paypal`, with `card` for a card brand), so an agent knows before it pays; and, while the owner takes [Stripe Pay](/docs/payments/#stripe-pay), `stripe_pay` among them and `pay_to` with the business’s Stripe profile handle and the currencies it takes there. What the business runs (Settings → General, What you run) is in `categories`, after the owner’s own: the kind’s category slugs from the [business kinds](https://github.com/surfingdogai/inbox/blob/main/packages/spec/vocab/business-kinds.json), then its name, or the owner’s own words, as a free tag. When the business takes currencies besides its own, `currencies` lists them, its own first; its prices stay in its own. The inbox never puts a contact email, a phone or the legal address in it. Absent until the business has a name. What else an instance sends each network it reports to is on [Security and privacy](/docs/security-and-privacy/). | | `item_types` | Which of `message`, `quote_request`, `booking`, `order`, `refund` this instance really accepts, from what it offers: `message` always; `booking` with an active service booked at a time (not one asked about for a price) that has weekly hours someone saved, its own or the business’s; `order` with an active product; `quote_request` with either; `refund` with orders, or with bookings of a priced service. A fresh inbox lists only `message`. The owner’s switches for what agents can do (`doors` in the settings: `booking`, `quoteRequest`, `message`) come first: `false` leaves a door out whatever the inbox holds, and an agent that asks there anyway is refused with `403 not_allowed`, `details.reason: "door_shut"`; `quoteRequest: true` lists `quote_request` before anything is listed. People who write by email or on the business’s own page are not agents, and the switches leave them as they are. Where no booking is listed, an agent’s search for times or booking is refused with `403 not_allowed`, `details.reason: "booking_not_offered"`: no time is offered in hours nobody set. A service asked about for a price is never booked, by anyone: `422 invalid_input`, `details.reason: "quote_only"`. | | `protocols` | Protocol name to entry URL. Today: `openapi`, `rest`, `mcp`. Adapters add their own keys as they ship (`a2a`, `ucp`, `arp`, `email`, `form`). The owner’s own door, `/mcp/owner`, is not listed: it is for the owner’s AI, not for anyone reading the manifest. | | `agent_policy.tiers` | The trust tiers the instance serves, from `anonymous`, `signed_agent`, `verified_principal`, `reputed_principal`. | | `agent_policy.signatures` | Request signatures it verifies on every public door: `sdi-agent/1` (HTTP Message Signatures with the agent’s own key) and `web-bot-auth` (a platform’s key directory, named by `Signature-Agent`). | | `agent_policy.passes` | Whether it presents a person’s passes and keys to their networks (it needs `INBOX_SECRET_KEY` and a public address). | | `agent_policy.networks` | The networks switched on in Settings → Networks: whose people it recognises, and which may issue a first-time customer a key through it. | | `agent_policy.guide` | How an agent identifies itself and its person here, step by step. | | `receipt_keys` | A JWKS with the instance’s Ed25519 receipt-signing keys, the same keys `/.well-known/jwks.json` serves. Empty on an instance that has never issued one, or that has no `INBOX_SECRET_KEY` to seal a key with. | | `review_services` | The networks this instance publishes its [receipts](/docs/receipts/) to, by origin: every network switched on in Settings → Networks that takes receipts. Empty means none. `https://network.surfingdog.ai` is only the default. The field keeps its original name. | | `directory` | `{"listed": false}` when the business has left the networks’ directories: the owner switched the listing off, or every network switched on is kept from it. Absent means listed. A network reads it when it fetches the manifest; one this inbox can sign to is also told at once with a signed `POST /v1/instances/{domain}/listing`, which is the only way to leave one network’s directory and stay in another’s. | | `doors_elsewhere` | The kinds of request the business takes somewhere else, and where: `[{"item_type": "booking", "url": "https://…", "platform": "fresha"}]` for bookings at a booking platform, `"order"` for orders in its own shop’s checkout. `platform` names the platform the page is on, from the [door platforms](https://github.com/surfingdogai/inbox/blob/main/packages/spec/vocab/door-platforms.json), when the page is the business’s own page there; it is absent for any other page ([Booked or ordered elsewhere](/docs/connectors/elsewhere/)). A kind named here is never in `item_types`: an agent goes to that address for it, and to the inbox for everything else. A customer’s booking or order of that kind, and the free times for one, are refused with `403 not_allowed`, `details.reason: "taken_elsewhere"` the address in `details.url` and, for a platform, its token in `details.platform` and its name in `details.place`, so an agent that never read the manifest is sent there too; the business still adds one itself. It comes from `doorsElsewhere` in the settings document, which the owner in person or a key with `settings:write` sets, never the owner’s AI; absent when there are none. A reader keeps each entry alone or drops it alone, as `readDoorsElsewhere` in `@surfingdog/spec` and its vectors (`vectors/doors-elsewhere.json`) set out: one of the five item types, once, and an https address on a named host. | The schema is a Zod object in [`packages/spec/src/index.ts`](https://github.com/surfingdogai/inbox/blob/main/packages/spec/src/index.ts). The instance builds the document from its business profile and serves it with a five-minute cache header. ## Finding the manifest [Section titled “Finding the manifest”](#finding-the-manifest) Besides the well-known path, a business may point at its instance from its main domain with a DNS record:
```plaintext
_agent-inbox.example.com. TXT "v=sdi1; manifest=https://inbox.example/.well-known/agent-inbox.json"
```
The same URL will be cross-linked from the A2A card, the UCP profile, `llms.txt` and a `` on the website as those adapters arrive. To see whether a reader finds your manifest from your main website, [check your website for AI agents](/check): the result names each door it found, and how. ## A live one [Section titled “A live one”](#a-live-one) The live instance’s manifest is at [inbox.surfingdog.ai/.well-known/agent-inbox.json](https://inbox.surfingdog.ai/.well-known/agent-inbox.json).
# Surfing Dog MCP server
> One remote MCP server for any AI assistant. Find businesses, check a website, and, where a business has its own inbox, send it a question, a quote request or a booking request. No sign-in.
Surfing Dog’s MCP server is at **`https://surfingdog.ai/mcp`** (also `https://network.surfingdog.ai/mcp`, and the same server at `/mcp/search`). Streamable HTTP, no sign-in, the same answer for everyone. Add it to your assistant as a custom connector: [the steps for each assistant](/assistants/). > Surfing Dog never touches the money and never keeps what anyone writes. When a business allows it, we carry an assistant’s request to the business’s own inbox and keep only a record that it was delivered. ## Tools [Section titled “Tools”](#tools) * `search_businesses`, read-only: finds businesses by a few words, a place, a category, a language, opening hours and what their doors take. * `get_business`, read-only: one business, with its doors, hours, address and track record. * `list_categories` and `list_attributes`, read-only: the categories and attributes a search takes. * `check_business`: how far an AI agent can go with a website, and its agentic score. Idempotent, open world. * `ask_business`: sends a business your person’s question, to its own inbox. Not destructive, idempotent, open world. * `request_quote`: asks a business for a price, in its own inbox. The same. * `request_booking`: a service’s free times in the business’s own time (nothing is sent), or a request for one of them, which the business confirms. The same. * `report_door`: records, privately for the business, how its door went when you used one. * `register_business` and `update_business`: for a business’s own AI, to put it in the directory, correct it or take it out, with proof of its domain. `update_business` is destructive. Every tool states `readOnlyHint`, `destructiveHint`, `idempotentHint` and `openWorldHint`. Their schemas are published in the [protocol](https://github.com/surfingdogai/inbox/tree/main/packages/spec/schemas) (`mcp-*.json`). ## Carried requests [Section titled “Carried requests”](#carried-requests) `ask_business`, `request_quote` and `request_booking` are for when your person asks you to contact a business. They go only to a business in the directory that has its own inbox, and only when its inbox says it takes them (an inbox from before the owner’s switch says nothing, and is carried nothing). The business can answer your person by email. * **Contact.** An email address your person agreed to give the business (`consent: true`), and a name if they want one. Never a phone number. * **The answer** is the inbox’s own: the request as it keeps it, its words, and an access token to read the request’s state at the inbox, given once. * **A priced booking** first answers with the business’s terms (`outcome: "confirm"`). Show them to your person; send `terms_sha` only after their yes. * **What we keep:** when, to which business, the kind of request, and how it went. Not who sent it, and not what it said. Nothing about your person goes back to your assistant’s platform, and there is no list of anyone’s requests. * **The inbox is told** that a network carried the request (`Surfing-Dog-Carried: 1`), with a code for its sender that changes daily and differs for each inbox (`Surfing-Dog-Carried-For`), so it can hold one sender to its limits. It does not say who the sender is. * **A business** switches them off in its inbox under Settings → Networks; its manifest says `carried_requests: {"accepted": true}` or `{"accepted": false}`. An example, as JSON-RPC:
```bash
curl -s https://surfingdog.ai/mcp -H 'Content-Type: application/json' -d '{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "request_booking", "arguments": {"domain": "bikes.example", "service": "Puncture repair"}}}'
```
## Limits [Section titled “Limits”](#limits) Counted per person where we can tell people apart: by a directory key, else by the session `initialize` gives (signed, stored nowhere), else by the address. An assistant platform’s published outbound range is many people, not a trusted caller: each address in it keeps its own allowance inside the range’s. Each number is the most in any minute, hour or day, and a request one limit refuses counts against none. * Reading: 600 requests a minute per address (1,200 inside a platform’s range), 300 per person. * `check_business`, `register_business`, `update_business`: 60 a minute per person, and their own limits. * Carried requests: 20 an hour per person; 3 an hour to one business per person and per email address; 60 an hour per address and per wider network (an assistant platform’s whole range: 200); 12 a day per email address; 4 an hour from one place to one business; 12 an hour and 60 delivered a day per business from everyone; the same words to at most 5 businesses an hour. * Free times: 60 an hour per person, 10 from one place to one business, 60 per business. * One JSON-RPC message per request, at most 16 KB. ## Tested with [Section titled “Tested with”](#tested-with) Tested on 1 November 2026 with Gemini CLI 0.66 and the MCP Inspector 2.13: a search on this server; and, against an inbox run on our own machine, a booking request and its status through the business’s MCP, and the owner’s inbox through the owner MCP, with a key, and with a token from the inbox’s own OAuth sign-in, which a script of ours went through rather than either client. Claude Code 2.1.72 connected to this server and to the inbox’s two, and found the owner’s sign-in; no Claude model ran yet. ## More [Section titled “More”](#more) * In plain words, for an AI: [network.surfingdog.ai/llms.txt](https://network.surfingdog.ai/llms.txt). * The server card: [/.well-known/mcp/server-card.json](https://surfingdog.ai/.well-known/mcp/server-card.json). * The protocol, §4.7 and §4.17: [network.md](https://github.com/surfingdogai/inbox/blob/main/docs/protocol/network.md). * [Privacy](/privacy/), [terms](/terms/), [support](/support/).
# Restaurants, bakeries and cafés
> Your menu with its options and allergens, orders to collect or have delivered at a time your kitchen can make, the pause, tables by covers, and the ordering system or till you already run.
A customer’s assistant can read your menu, pick a time your kitchen can make, choose a size and a crust, ask for no onions, and add a tip, then place the order with you. You set the prices, what each option adds, how many orders a slot takes, and what your dishes contain. Nobody takes a share of an order, and you are paid on your own payment link. Settings → **Menu** and **Kitchen**. Setting up a restaurant, a bakery or a café asks for your first dishes, with their prices: add the rest, and their allergens, in Settings → Menu. ## Your menu [Section titled “Your menu”](#your-menu) * **A dish**: **+ A dish** at the foot of a section, its name and price, then the rest in its editor. It is a product like any other, so Settings → Services → Products lists it too. * **Sections**, in your order: Starters, Pizzas, Drinks. * **Options** on a dish: a size, a crust, a milk, extras. Each group says whether it is required and how many may be chosen, and each choice what it adds. A choice can open one other group (a large pizza opens its crust). A group can take **halves**, for half-and-half, with a half price of its own where you want one. What each option adds is always the menu’s, whatever an assistant sends. * **When it is made**: the days of the week, how far ahead it must be ordered (a birthday cake: 2 days), a **daily limit** for what you set aside, and **made to order** for something made for one customer. Made-to-order dishes come to you to decide, and are not returnable. * **An age limit**, for alcohol. * **Dietary tags**: vegan, vegetarian, gluten free and the rest. A tag the dish’s own allergens contradict cannot be chosen: gluten free on a dish that contains wheat, or may. * **Sold out today**: one press beside the dish. It is back on tomorrow by itself. * **Returns**: a dish on the menu does not keep, so it is not returnable; your customers are told so before they order, and in their emails. A table is for a set date, and the same holds. You can start from the menu in your ordering system: Settings → Menu → **Bring in your menu**, then tick the dishes to keep. Your prices stay as your system has them. Bringing a dish in again takes its name, price and options from your system and keeps everything you set here: its limits, days, notice and allergens. ## Allergens [Section titled “Allergens”](#allergens) What a dish **contains**, and what it **may contain** from shared kitchens or oil, pressed in a grid. Where you are decides which come first: the fourteen in the EU and the UK, the nine in the US, Canada’s, Australia and New Zealand’s, Japan’s. Every one of the 46 is under **All**, with each nut and cereal. * **Never guessed.** Your Inbox never reads a dish’s name for allergens. Lists brought in from your ordering system, or written by your assistant, arrive **not confirmed**. * **Only you or your staff confirm**, in the app: **Confirm allergens**, then **Confirm, as shown**. Your assistant cannot, and neither can another system’s key. Changing a dish’s allergens, or a choice’s, makes them not confirmed again, and a confirm never holds for lists someone changed while you looked. * **Not confirmed means ask.** Assistants tell people to ask you, never that a dish is free from anything, and an order with such a dish waits for you to accept it. ## Orders for a time [Section titled “Orders for a time”](#orders-for-a-time) Switch on **Take orders for a time**, then choose: * **How people get it**: collection, delivery, eating in. * **Ready at the earliest in**: the first time offered is at least that far off. * **Slots** of 10, 15, 30 or 60 minutes inside your opening hours, and **orders per slot**, kept for orders from agents: walk-ins and your till are separate. A slot is never sold twice: of two assistants asking for its last place, one gets it and the other is told that time is full. * **How far ahead** people may order. * **Who accepts**: you accept each order (where every kitchen starts), or orders inside what you set aside are accepted at once. Anything else waits for you: a full slot, a dish past its daily limit, something made to order, allergens you have not confirmed. Accept one past a dish’s daily limit and the rest of that day’s limit goes to it, so the orders after it wait for you too. * **Tips**: people may add one, from the choices you pick, on your own payment link. ### Delivery zones [Section titled “Delivery zones”](#delivery-zones) Settings → Kitchen → **Delivery zones**, inside out: an address goes in the first zone it falls in. Each zone is a distance from your premises or a set of postal codes, with its delivery fee, a minimum order and how long it is on the road. An address outside every zone is told you do not deliver there. Your own drivers deliver: your Inbox never calls a courier. ### The pause [Section titled “The pause”](#the-pause) Busy? On the inbox’s home, **Pause** for 15, 30 or 60 minutes or the rest of today, and **Resume**. Anyone at the business can press it, on a phone too. Assistants are told when you take orders again. With Flipdish connected, Settings → Kitchen → **Pause** → **Close my Flipdish store too while paused** closes the store you chose for the same time; resuming opens only what the pause closed, never a closure you set in Flipdish. ### How far an order is [Section titled “How far an order is”](#how-far-an-order-is) On today’s orders, and on each order: **Start preparing**, **Mark ready**, and for a delivery **Out for delivery**. The customer’s assistant sees it. An order for another day says its day (“Tomorrow, 08:00”), and with nothing left today the kitchen card names the next day that has orders. ## Tables [Section titled “Tables”](#tables) Settings → Kitchen → **Tables**, on a service such as *Dinner*: book it **by covers**, with the largest party, a request for a price above a party size, a **deposit per person** from a party size, and the **covers set aside** for bookings through here. A party that fits in what is left of those covers, in a free sitting, is confirmed at once; a larger one, and every table when none are set aside, comes to you, and you seat it from the whole room. ## Bakeries and cafés [Section titled “Bakeries and cafés”](#bakeries-and-cafés) A bakery’s cakes can ask for the writing on them, how many it serves and a flavour, and a photo of what it should look like. A café’s orders ahead come in slots of 10 minutes, and a pastry can be sold out today with one press. ## Which tills [Section titled “Which tills”](#which-tills) | System | How it connects | What it does | Its developer documentation | | -------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | | GloriaFood | Your own keys, from its admin panel: Others → 3rd party integrations | Your menu, to start from. Orders accepted in GloriaFood, shown to you only if you switch it on, with no customer details | | | Flipdish | An app you make in your own Flipdish account: its client ID and secret | Your menu and collection hours, to start from; your store closed while you pause | | | Toast | **Not yet**: a Toast partner app on the server | Your menu, and orders you accept written into your till | | | Lightspeed Restaurant (K-Series) | **Not yet**: a Lightspeed partner app | Your menu by location, and orders you accept as to-go orders | | | Clover | **Not yet**: a Clover App Market app | Your menu, and orders you accept as Clover orders | | | Deliverect | **Not yet**: a certified Deliverect channel, and the webhook secret from its Integration settings | Orders you accept into your Deliverect channel, and its word on how far they are | | GloriaFood and Flipdish are built from each one’s documentation and tested against a stand-in; not yet verified on a live account. Nothing to press shows a button that goes nowhere: a system that needs its partner app on the server says **not yet** until it is there; until then, GloriaFood and Flipdish connect with what you make in your own account. On your own server: `TOAST_CLIENT_ID`, `TOAST_CLIENT_SECRET` and `TOAST_API_HOST`; `LIGHTSPEED_CLIENT_ID` and `LIGHTSPEED_CLIENT_SECRET`; `CLOVER_CLIENT_ID` and `CLOVER_CLIENT_SECRET`; `DELIVERECT_CLIENT_ID`, `DELIVERECT_CLIENT_SECRET` and `DELIVERECT_CHANNEL` (with `DELIVERECT_STAGING=1` against its staging). ## What a connection does [Section titled “What a connection does”](#what-a-connection-does) * **Your menu, to start from.** Read when you ask, never changed on its own. Allergens come in not confirmed. * **Orders you accept, once.** An order goes into your till when you accept it, or once it is paid when it goes through your payment link, never twice: a retry or a lost answer finds the one already there, or is answered by your till as the same order. Where neither holds (Clover), an order whose answer never came is left for you to check there, and you are told. * **Nothing left out.** A dish or a choice your till does not know is not sent without it: the order is marked for you to put in by hand. * **Your till’s word.** Accepted or refused in your till comes back as a note on the order; Deliverect’s progress becomes the order’s. An order cancelled here after it went in is marked for you to cancel it in your till too. * **No card data, no courier, no marketplace.** Payment stays on your own payment link, delivery with your own drivers. ## Building one [Section titled “Building one”](#building-one) Every ordering system is built from its published reference and tested against a stand-in of it (`packages/adapters/test/fakes/pos.ts`); none is yet verified on a live account. Decisions: [ADR-040](https://github.com/surfingdogai/inbox/blob/main/docs/adr/040-food.md).
# Payments
> Connect your own Stripe or Mollie account. Orders you accept get a link to pay into it, and are marked paid once the platform confirms it. The money never passes your Inbox; refunds stay in your dashboard.
Connect your own Stripe or Mollie account, and the orders you accept get a link to pay into it. When the customer pays, the platform tells your Inbox, your Inbox checks with the platform, and the order is marked paid. The money goes straight from your customer to your account: it never passes your Inbox, nor Surfing Dog. Settings → Integrations → **Stripe** or **Mollie**. **Stripe.** Tested on 1 November 2026 against Stripe’s test mode, with a test secret key standing in for a restricted one (the check that refuses secret keys was set aside for the test): connected, with the inbox adding its webhook; a payment link for an accepted order; a declined card; paid, as found by the inbox’s own sync, and as told by Stripe’s own event, which we fetched from Stripe and passed on signed with the webhook’s secret, since Stripe could not reach our machine; the link closed on cancel and on disconnect; a refund made in Stripe and recorded in the inbox. Not yet with a restricted key, a webhook Stripe delivers itself, a bank payment that clears later, or on a live account. **Mollie.** Built from its documentation and tested against a stand-in; not yet verified on a live account. ## What it does, and what it never does [Section titled “What it does, and what it never does”](#what-it-does-and-what-it-never-does) * **Accepted orders get a link.** When you accept an order, or a customer accepts your quote, your Inbox makes a link for what it comes to in your account and emails it to the customer in your name, in the same email as the acceptance and your words with it (if the platform is slow to answer, the link follows in a second email). Agents see it on the order too. * **One link at a time, for the amount it comes to.** No link is made while a change to the order waits for an answer. Once payment is asked for, the total is fixed: more is a second order, less a refund. If the order is cancelled, its link is switched off; if a bank payment fails, that link is switched off and a new one sent. * **Every currency in its own minor unit.** Your Inbox keeps each amount in its currency’s own unit, as ISO 4217 counts it: KSh 45.00, ¥4,500, KWD 4.500. Each platform is asked in its own count of that currency, from a table with a test for every currency it lists. A currency the platform does not list, or counts two ways (Stripe and dinars, Stripe and Ugandan shillings, Mollie and Kenyan shillings), gets no link, and you are told once, with the reason; the customer pays you another way. * **Paid means the platform said so.** A webhook is never believed on its word: Stripe’s signature is checked, and in every case your Inbox asks the platform, with your key, before it marks anything paid. * **The receipt says what was paid.** A paid order’s receipt carries the amount the platform confirmed, and how it was paid when the platform says. * **Refunds stay in your dashboard.** Your Inbox never refunds, charges or moves money. A paid order links to the payment’s own page in Stripe or Mollie: **Refund in Stripe**. * **Test keys for test orders.** A test-mode key makes links only for test orders, a live key only for real ones. * **Your own link stays yours.** If you ask for payment with a link of your own, your Inbox leaves that order alone. * **Nothing about your customer is sent from here.** No name, email or address goes to the platform with the link, and the link is named by your catalogue’s own product names, or “Order 4F2K9Q”, never by words a customer typed: the platform asks for what it needs on its own page. * **One platform sends your links.** Connect Stripe and Mollie both and the one you connected last sends the links of its mode (live or test); switch links on for the other in **Change** to send them from it instead. Links already sent keep working either way. Orders you accepted before connecting keep what they had: a business paid on delivery is never asked to pay by surprise. To send one of them a link, ask for payment on the order without a link of your own. Bookings, and quotes that become bookings, get no link yet. If a payment arrives that cannot go on its order (paid after the order was cancelled, paid twice, or another amount), you get one email with the payment’s page, so you can refund it there. If the platform will not make a link (an amount below its minimum, a currency it does not take), you get one email, and the order waits for you as accepted; it is tried again when you ask for payment, or when the total, the currency or the connection changes. A refund you owe on an order paid through the platform is made in its dashboard: the refund shows **Refund €45.00 in Stripe**, and **Refunded in Stripe** records it in one click, against the payment. With Mollie, whose key can read refunds, a refund made in Mollie’s dashboard marks the refund done on its own, within about an hour. ## Stripe [Section titled “Stripe”](#stripe) Create a **restricted key** for your Inbox in Stripe: **Developers → API keys → Create restricted key**, and give it only this: | Permission | | | ----------------- | ----- | | Payment Links | Write | | Products | Write | | Prices | Write | | Webhook Endpoints | Write | | Checkout Sessions | Read | Every other permission starts at *None*; leave it there, and paste the key (`rk_live_…`, or `rk_test_…` to try it first). Your Inbox checks every permission before it keeps anything, and names any that are missing. A secret key (`sk_…`) is refused: it can do anything in your account. It never uses Stripe Connect. With *Webhook Endpoints: Write*, your Inbox adds its webhook to your account itself, and looks at it on every sync while a link waits to be paid: one Stripe switched off is switched on again, and one removed is said in Settings and by email. Without it, add it yourself in **Developers → Webhooks**: the address and the three `checkout.session` events are under **More options** in Settings, and so is the field for its signing secret (`whsec_…`). On a hosted inbox, the webhook is added once your inbox is live on its own address; until then your Inbox asks Stripe about each link on its five-minute sync. Your Inbox does not ask Stripe which account a key belongs to, so one Stripe account connects per mode: one live, one test. To change the key, use **Change → New key** on the connection (or the Stripe tile): your orders and their links stay as they are. ## Mollie [Section titled “Mollie”](#mollie) Paste your profile’s **API key**: in Mollie, **Developers → API keys**, the *Live API key* (or the *Test API key* to try it first). Each link tells Mollie where to report its payment, so there is nothing else to set up. Mollie reports a payment without a signature, only its id; your Inbox then asks Mollie, with your key, what was paid on that link. If you want Mollie to sign its reports as well, add a webhook in **Developers → Webhooks** for `https:///v1/hooks/mollie` with the `payment-link.paid` event, and give its signing secret over the API (`values.webhook_secret` when you connect): signed reports are then checked by it, and read back from Mollie the same way. ## Stripe Pay [Section titled “Stripe Pay”](#stripe-pay) Settings → Payments → **Stripe Pay**: type your Stripe profile handle (`@yourbusiness`, or its profile id, `profile_…`) and tick the currencies you take there. Other businesses can then pay you from their Stripe Dashboard, or have their AI agent do it with Stripe’s own tools. Stripe offers Stripe Pay in preview to eligible businesses in the US and the UK, between Stripe accounts, in USD, GBP and USDC: the one paying needs a funded Stripe financial account, and you need a Stripe financial account or a bank account for payouts linked to your Stripe profile. * **Where it shows.** Your manifest declares it (`stripe_pay` in `profile.payments`, your handle in `profile.pay_to`), your business profile and your page say it, and an accepted order in a currency you take there shows it beside any link: “If you pay from a Stripe business account, you can pay with Stripe Pay to @yourbusiness: £29.00, reference 4F2K9Q.” The emails about paying an order say the same. Most customers have no Stripe business account, so it sits beside your other ways to pay rather than replacing them. * **Nothing here sees it.** The transfer goes from their Stripe account to yours, never through your Inbox, and nothing tells your Inbox of it. A customer can say they paid when they have not: check the money in Stripe, then open the order and **Record payment**, with the transfer as the reference. * **Agents confirm first.** An agent is told to confirm your handle, the amount, the currency and which of its person’s Stripe financial accounts pays it before anything is sent, as Stripe’s guidance asks. Stripe documents no note on a transfer, so the agent adds the order’s reference if Stripe lets it, and either way tells you it paid in a message that gives the reference. * **Never for a test order**, nor for an order in a currency you did not tick. An order is never in USDC, so it is offered Stripe Pay in USD or GBP; if your prices are in neither, the tab says no order will offer it. * **Only you set it**, signed in or with your own full owner key: your AI and keys you give to other systems cannot change where you are paid. Tested on 1 November 2026, in an inbox run on our own machine: a handle and its currencies save, a handle that is not one is refused, and your manifest and an accepted order’s status show them. No Stripe Pay transfer has run yet. ## For agents [Section titled “For agents”](#for-agents) An accepted order that waits for payment is in state `awaiting_payment`, with the link in `payload.paymentUrl`: give it to your person. When the business takes Stripe Pay, the order’s status has `payment.pay_to` with its handle, the exact amount in its currency and the order’s reference: confirm the recipient, the amount, the currency and which of their Stripe financial accounts pays it with your person before sending. Add the reference to the transfer if Stripe lets you; either way, say you paid with `send_message`, giving the reference. The business marks it paid once it arrives. Once the platform confirms the payment the order moves to `paid`, and its receipt (`knd: paid`) carries `amt` and, when known, `pay`. A payment link is the business’s own: pay only there. ## Secrets [Section titled “Secrets”](#secrets) The key and any signing secret are sealed with your `INBOX_SECRET_KEY` (AES-256-GCM) before they are stored, and no answer of any door ever carries them back, to you or to your AI. Without `INBOX_SECRET_KEY` nothing can be connected. Every call to Stripe or Mollie goes over https to a public address with the same checks as [calendars](/docs/calendars/#secrets). Your Inbox needs its own public `https://` address for the platform to reach it: `INBOX_PUBLIC_URL`, or the Inbox address in Settings. ## Over the API [Section titled “Over the API”](#over-the-api)
```bash
# Connect Stripe
curl -X POST https:///v1/owner/integrations \
-H "authorization: Bearer sdi_own_…" -H 'content-type: application/json' \
-d '{ "kind": "stripe", "values": { "api_key": "rk_live_…" } }'
# An order's links, what was paid, and where to refund it
curl https:///v1/owner/items//payments -H "authorization: Bearer sdi_own_…"
```
`PATCH /v1/owner/integrations/{id}` with `{"pay_on": false}` stops new links (`true` makes this connection the one that sends them, and stops the other of its mode). `DELETE …/{id}` disconnects: it makes no new link at once, switches off the links still waiting to be paid (a few at once, the rest within two hours, tried again when the platform does not answer: `links_closing` says how many are left), and keeps hearing of their payments until then; then the webhook your Inbox added is removed, and you get one email naming the orders that were left waiting on a link that no longer works, and any link that could not be switched off. The platforms report to `POST /v1/hooks/stripe` and `POST /v1/hooks/mollie`. `GET …/items/{id}/payments` says, in `none`, why an order that could have a link has none. Your AI, over the owner MCP, can `list_integrations` and `list_payments`; it records no payment and connects nothing.
# On a platform
> Surfing Dog Inbox on Coolify, Fly.io, Render, Railway and a DigitalOcean Droplet, with what each needs, and why DigitalOcean App Platform is not on the list.
The inbox keeps its data in one SQLite file. A platform runs it well when it gives the container a disk that lasts across deploys and restarts, and runs exactly one copy of it: a second copy would have a database of its own. Each platform below does, in the way described; where one does not, it says so. All of them run the same image, `ghcr.io/surfingdogai/inbox`, pinned to a version (`0.2.0` here), with the same settings as anywhere ([Quickstart](/docs/quickstart/#your-own-server)): `INBOX_OWNER_EMAIL` and `INBOX_SECRET_KEY` to start, then `INBOX_PUBLIC_URL` once the inbox answers at your own address. Generate the key (`openssl rand -base64 32`) and keep a copy somewhere safe, apart from the platform. And give the inbox an address on your own domain, such as `inbox.yourbusiness.com`: agents and checkers start from the business’s domain, and an address on the platform’s domain is one they don’t credit. The inbox limits each caller by their address. Most platforms’ proxies pass a `CF-Connecting-IP` header the caller wrote straight through, so each template sets `INBOX_CLIENT_ADDRESS_HEADER` to the header the platform’s proxy sets itself. | | Disk | One click | Cost | | ----------------------------- | ------- | ------------ | ----------- | | [Coolify](#coolify) | Volume | Compose file | Your server | | [Fly.io](#flyio) | Volume | `fly launch` | Usage | | [Render](#render) | Disk | Blueprint | A paid plan | | [Railway](#railway) | Volume | Steps | Usage | | [DigitalOcean](#digitalocean) | Its own | First boot | The Droplet | ## Coolify [Section titled “Coolify”](#coolify) Coolify runs on your own server and puts its own proxy, with a certificate, in front of what it runs. 1. **New resource → Docker Compose Empty**, and paste [deploy/coolify/docker-compose.yml](https://github.com/surfingdogai/inbox/blob/main/deploy/coolify/docker-compose.yml). Or **Public Repository**, `https://github.com/surfingdogai/inbox`, with the Compose file at `/deploy/coolify/docker-compose.yml`. 2. Set the inbox’s domain to `https://inbox.yourbusiness.com`, and point that name’s DNS at the Coolify server. 3. Fill in `INBOX_OWNER_EMAIL`, and deploy. Coolify generates `INBOX_SECRET_KEY` once and keeps it; copy it from the environment variables somewhere safe. The data is in the `inbox-data` volume. Coolify’s scheduled backups cover databases it runs itself, not this file: run `docker exec node /opt/inbox/server.mjs backup /opt/inbox/data/backups/inbox-$(date -u +%Y%m%dT%H%M%SZ).db` on a schedule (each backup is a new file; the command never overwrites one), or [Litestream](/docs/backups/#litestream). The version is pinned, so a redeploy stays on it: to update, back up, then set `INBOX_VERSION` to the new one and redeploy. Coolify’s proxy sets `X-Real-Ip` itself, which the template’s `INBOX_CLIENT_ADDRESS_HEADER` names. ## Fly.io [Section titled “Fly.io”](#flyio) One machine, with one volume mounted at `/opt/inbox/data`. Fly volumes belong to root, so [deploy/fly/Dockerfile](https://github.com/surfingdogai/inbox/blob/main/deploy/fly/Dockerfile) starts the image as root once: it gives the volume to the inbox’s own user and runs as that user from then on.
```bash
git clone https://github.com/surfingdogai/inbox && cd inbox/deploy/fly
fly launch --copy-config --no-deploy # pick a name and a region; no database is needed
KEY=$(openssl rand -base64 32); echo "$KEY" # put it in your password manager now: Fly never shows it again
fly secrets set INBOX_OWNER_EMAIL=you@yourbusiness.com INBOX_SECRET_KEY="$KEY"
fly deploy
fly certs add inbox.yourbusiness.com # then add the DNS record it names
fly secrets set INBOX_PUBLIC_URL=https://inbox.yourbusiness.com
```
[fly.toml](https://github.com/surfingdogai/inbox/blob/main/deploy/fly/fly.toml) keeps the machine running (the inbox works between requests: rules, reminders, a network’s hourly ping) and checks `/healthz`. Never scale it past one machine. Fly keeps a daily snapshot of the volume, 14 days here. It also sets `INBOX_CLIENT_ADDRESS_HEADER=fly-client-ip`, the header Fly’s proxy sets itself. The Dockerfile’s `FROM` line pins the version: to update, back up, change it, and `fly deploy`. ## Render [Section titled “Render”](#render) [Deploy to Render](https://render.com/deploy?repo=https://github.com/surfingdogai/inbox) reads [render.yaml](https://github.com/surfingdogai/inbox/blob/main/render.yaml): a web service from the image, a 1 GB disk at `/opt/inbox/data`, a health check on `/healthz`, and `INBOX_SECRET_KEY` generated for you. It asks for `INBOX_OWNER_EMAIL`. What to know first: Render gives disks only to paid instances, so the Blueprint uses the Starter plan, not the free one. A service with a disk runs one instance, and a deploy stops the old one before the new one starts, so it is offline for those seconds. Render snapshots the disk daily. Add your domain under **Settings → Custom Domains**, then set `INBOX_PUBLIC_URL` to it. Render’s disks belong to root, so [deploy/render/Dockerfile](https://github.com/surfingdogai/inbox/blob/main/deploy/render/Dockerfile) does as Fly’s does, and pins the version too. Automatic deploys are off: to update, back up, change the version in your copy of that file, and deploy. ## Railway [Section titled “Railway”](#railway) There is no Railway template to press yet; from the image it takes a minute: 1. **New project → Deploy a Docker image**: `ghcr.io/surfingdogai/inbox:0.2.0`. 2. **Add a volume** to the service, mounted at `/opt/inbox/data`. 3. **Variables**: `INBOX_OWNER_EMAIL`, `INBOX_SECRET_KEY` (generated, kept), `INBOX_CLIENT_ADDRESS_HEADER=x-real-ip` (the header Railway’s edge sets itself), and `RAILWAY_RUN_UID=0`. Railway’s volumes belong to root, and this starts the container as root once: the image gives the volume to its own user and runs as that user from then on. 4. **Settings → Networking**: a custom domain, `inbox.yourbusiness.com`, with the CNAME it names. Then `INBOX_PUBLIC_URL=https://inbox.yourbusiness.com`. Keep it at one replica. Railway restarts it on the same volume; for copies elsewhere, use [Litestream](/docs/backups/#litestream). ## DigitalOcean [Section titled “DigitalOcean”](#digitalocean) **App Platform cannot run the inbox**: its containers have no disk that lasts between deploys, and the inbox’s database is a file, so every deploy would start it empty. A **Droplet** runs it with the [one-command installer](/docs/run-it-yourself/#one-command-on-your-server): 1. Create a Droplet: Ubuntu 24.04, 1 GB is plenty. Under **Advanced options → Add initialization scripts**, paste [deploy/digitalocean/cloud-init.yml](https://github.com/surfingdogai/inbox/blob/main/deploy/digitalocean/cloud-init.yml) with your address and email in it. 2. Point the address’s A record at the Droplet’s IP. A Reserved IP lets you add the record before the Droplet exists. 3. **Sign in.** Over SSH, `sudo inbox sign-in` gives a sign-in link, and a code for `https://inbox.yourbusiness.com/login`. What the installer did is in `/var/log/inbox-install.log`. The installer runs once, at first boot, and the certificate comes as soon as the record points there. Or create the Droplet as it is, and run the one command over SSH. DigitalOcean’s own backups of the Droplet are an option on top of the inbox’s nightly ones. ## Cloudflare Workers [Section titled “Cloudflare Workers”](#cloudflare-workers) Not a container: the same code on Workers, with D1. [Run it yourself → Cloudflare Workers](/docs/run-it-yourself/#cloudflare-workers).
# Property systems
> Where your rooms already live. Which systems your Inbox connects to today, which wait on their company, and what a connection does.
A property system (a property management system or a channel manager) is where a place to stay keeps its rooms, its calendar and its bookings. Connected, your Inbox reads what is free from it and writes each stay to it first, so a room is never sold twice. Settings → **Property** → **Connect your system**. ## In this release [Section titled “In this release”](#in-this-release) **Calendar links, one per room.** Each listing on Airbnb, Booking.com or Vrbo has a calendar link. Add it in Settings → Property → **Calendar links**, choosing which room it is in the same sheet. The nights it shows as taken appear as hints: in the Nights grid, to guests’ assistants as *likely taken*, and as a request for you to look. A link lags hours behind, so a hint never confirms or refuses a stay on its own. **Beds24**: [connect with an invite code](/docs/connectors/beds24/). **Hostaway**: [connect with your account ID and API key](/docs/connectors/hostaway/). Beds24 and Hostaway are built from each one’s documentation and tested against a stand-in; not yet verified on a live account. ## Not switched on yet [Section titled “Not switched on yet”](#not-switched-on-yet) Built, and switched off until each company says yes. Each shows **Soon** or **Needs setup**, with nothing to press; until then, stays come to you as requests. * **Smoobu** (built, switched off until Smoobu’s go-ahead): [how it connects, with your API key and secret](/docs/connectors/smoobu/). * **Mews** (built, switched off until a Mews partner token is on the server): [what it needs](/docs/connectors/mews/). * **apaleo** (built, switched off until apaleo’s connect client is on the server): [what it needs](/docs/connectors/apaleo/). * **Cloudbeds** (built, switched off until Cloudbeds approves its partner app): [what it needs](/docs/connectors/cloudbeds/). ## What a connection does [Section titled “What a connection does”](#what-a-connection-does) * **Your rooms, read once.** You choose which property in your system this is, and match each of its rooms to yours (or make them here, named and counted as your system has them). * **What is free, kept fresh.** Read every 15 minutes while stays are near, every few hours otherwise, and asked again at the moment a guest holds or books; if your system does not answer then, the stay is on request. A change your system announces is read again before anything is offered. * **Stays written there first.** A stay waits until your system has taken it. If it refuses, or another booking took the room a moment before, nothing is promised: the guest is told the room could not be booked, and may ask again. A change of dates is written there before it is made here; if your system refuses it, the stay keeps its dates in both places, never cancelled. * **Never on an answer it cannot check.** A system that takes a booking even on a full night must say so itself (its own overbooking flag); the inbox reads it back, and takes its own booking back if it oversold, or if it cannot read it back at all. * **Nothing at once until you say so.** A newly connected system starts with you answering each request. Confirming at once can be switched on only after a first stay has been written and read back, and only while your system’s answer is recent (5, 15 or 60 minutes, your choice). * **Guests’ details only for confirmed stays,** and only to your own system: a stay written while its deposit was due is written again as confirmed, with its guest, once paid. * **The same money and time.** A property here and in your system keep the same currency and time zone; a mapping that differs is refused until they match. ## Building one [Section titled “Building one”](#building-one) Every connection is built against the same contract, from the system’s own documentation, and tested against a stand-in of it; none is yet verified on a live account. The contract and the fake are in the repository (`packages/adapters/test/property/`).
# Quickstart
> Let us host it, deploy to Cloudflare, or run it on your own server. Then make your first calls.
Three ways to run the same software. Pick one, then make the first calls at the bottom of this page. ## Hosted by us [Section titled “Hosted by us”](#hosted-by-us) We run the same software for you on our own servers, free. Pro, at $10, €10 or £9 a month, taxes included, is there if you want it. You set it up from your website’s check result, and it goes live at a hostname on your own domain, such as `inbox.yourbusiness.com`, once you add one DNS record. Your inbox and its backups are stored in the European Union. [Get your agent inbox](https://app.surfingdog.ai/start), or read the [step-by-step guide](/docs/get-your-agent-inbox/) first. ## Deploy to Cloudflare [Section titled “Deploy to Cloudflare”](#deploy-to-cloudflare) [Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https://github.com/surfingdogai/inbox) copies this repository to your GitHub or GitLab account and deploys it to your Cloudflare account. It creates a D1 database and a queue, and a cron runs every five minutes. The free plan runs it. Sending email needs Workers Paid. ### Before you press it [Section titled “Before you press it”](#before-you-press-it) * A Cloudflare account. * A secret key. Run `openssl rand -base64 32` and keep the result somewhere safe. The database needs it. * For email, optional: a domain on Cloudflare, onboarded to Email Sending (dashboard → Email Service → Email Sending → Onboard Domain), on Workers Paid. Pick the address the inbox sends from, like `inbox@yourdomain.com`. You can do this later. ### What it asks [Section titled “What it asks”](#what-it-asks) | Value | What to put | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `INBOX_OWNER_EMAIL` | Your email address. Only this address can sign in the first time. | | `INBOX_SECRET_KEY` | The key from above. It locks stored secrets and signs [receipts](/docs/receipts/). Empty, the inbox runs but stores no secret and issues no receipt. | | `MAIL_FROM` | The address to send from. Leave it empty if email isn’t set up yet. | Leave the build and deploy commands as they are. The build makes the owner app; the deploy is `npx wrangler deploy`. ### After it deploys [Section titled “After it deploys”](#after-it-deploys) 1. Open `https://..workers.dev/healthz`. Expect `{"ok":true,…}`. Migrations run on the first request. 2. Open `/login` and enter your address. If `MAIL_FROM` works, an email comes with a link and a code: open the link and press Sign in, or type the code on the sign-in page. Each works once, for 15 minutes. Without `MAIL_FROM`, the email is in the Worker’s logs instead: open the Worker in the dashboard, go to Logs and search for `sign-in email`, or run `npx wrangler tail ` and ask again. If sending fails, for example because the domain is not onboarded yet, the logs say why and hold no link: onboard the domain, or delete `MAIL_FROM` while you set up. If the log says your address is not in `INBOX_OWNER_EMAIL`, fix that value under Settings → Variables and Secrets. 3. The setup wizard opens. Your first sign-in saves the address you signed in at as the Inbox address (Settings). Links in emails use it, and receipts name it as their issuer. The wizard asks what you run (eight quick picks, hundreds of kinds to find by name in five languages, or your own words), who you are (name, time zone, your currency and any others you take, the languages you speak), what agents can do (book, order, ask for a price, suggest their own price, change what was agreed, write to you, and how customers pay you), what you offer, your hours, and the rules to start from. Products, a menu or rooms can be added a few now, brought from the shop, till or property system you already use, or added later; services are named in the wizard, and a calendar or booking tool you connect keeps their times in step. Ordering opens with your first product. Only the name is required; everything else can wait, and all of it is in Settings. Run again, the wizard starts from what Settings already say. 4. Your own subdomain: Worker → Settings → Domains & Routes → Add → Custom domain. Then change the Inbox address in Settings, or add `INBOX_PUBLIC_URL` as a secret under Settings → Variables and Secrets. 5. Email later: onboard the domain, then add `MAIL_FROM` as a secret there. Or add `RESEND_API_KEY` to send through Resend. Add these as secrets, not text: the next deploy from your repository removes a plain-text variable. 6. Email in arrives through Email Routing once you route an address to the Worker and write it in Settings → Email: then each conversation has its own reply address, and you can forward your existing address in ([Email](/docs/email/)). Every push to your copy of the repository deploys again. To update, pull this repository into your copy, and keep your own `database_id` in `wrangler.jsonc`. ## Your own server [Section titled “Your own server”](#your-own-server) On a fresh Ubuntu or Debian server, one command installs it with HTTPS, a nightly backup, and updates that undo themselves if they fail:
```bash
curl -fsSL https://surfingdog.ai/install.sh | sudo sh
```
[Run it yourself](/docs/run-it-yourself/) has what it does, the `inbox` command it leaves behind, and the other ways: the Docker image on a platform you use, or Compose by hand. The rest of this section is the server itself, for running it without the installer. Node 22.16, 24 or 26 (`node:sqlite` with FTS5), one process, one SQLite file. Every release has the built server as `inbox-server.tar.gz`, with `SHA256SUMS`; or build the owner app and the server bundle from the repository:
```bash
git clone https://github.com/surfingdogai/inbox && cd inbox
pnpm install
pnpm --filter @surfingdog/inbox build # apps/inbox/dist/client and apps/inbox/dist/server.mjs
node apps/inbox/dist/server.mjs # http://127.0.0.1:8787, database in ./data/inbox.db
```
The server serves the owner app from `client` next to the bundle, where the build and the release’s `inbox-server.tar.gz` both put it; any path that is neither a door nor a file gets the app shell. To sign in the first time, start it with `INBOX_OWNER_EMAIL` set to your address: with no email set up, the sign-in email is printed where the server runs. Environment variables, all optional: | Variable | Default | Meaning | | -------------------------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `PORT` | `8787` | Port to listen on. | | `HOST` | `127.0.0.1` | Interface to bind. Loopback, for a server behind a proxy that terminates TLS; the Docker image sets `0.0.0.0` inside its container. | | `INBOX_DB` | `./data/inbox.db` | The SQLite file. Created, and migrated, at boot. | | `INBOX_STATIC` | `client` beside the bundle | The built owner app to serve. | | `INBOX_PUBLIC_URL` | derived from the request | The https URL people and agents reach you at. Set it behind a proxy, or forward `X-Forwarded-Proto`; it feeds the manifest, the links in emails and the `iss` of every [receipt](/docs/receipts/). Background jobs have no request to read it from, so without it they use the Inbox address in Settings, which your first sign-in over https fills in. The links in sign-in and address-change emails use the same address once there is one, never the host a request names. | | `INBOX_OWNER_EMAIL` | unset | Comma-separated addresses allowed to create the first account by email link. Without it, the first sign-in needs an owner API key. | | `INBOX_SECRET_KEY` | unset | Seals connector credentials, webhook secrets and the receipt-signing key (AES-256-GCM), and is the source of the pseudonym pepper in receipts. One long random string, or several comma-separated and newest first, so a key can be rotated without downtime. Without it the instance runs as normal, refuses to store a secret rather than store it in the clear, and issues no [receipts](/docs/receipts/). | | `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_EMAIL_TOKEN`, `MAIL_FROM` | unset | Real email through Cloudflare Email Service (sign-in links, notifications), from an address on a domain onboarded there. `MAIL_FROM_NAME` is optional. | | `RESEND_API_KEY` | unset | Real email through Resend instead, from `MAIL_FROM`. With neither, mail is printed to the console, sign-in emails included. | | `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET` | unset | Your own Google OAuth client, so owners can connect Google Calendar (see [Calendars](/docs/calendars/)). Both or neither; without them Google shows as “set up” in Settings → Integrations. iCloud, Fastmail, Nextcloud, any CalDAV calendar, a calendar link and Cal.com need nothing here. | | `INBOX_OUTBOUND_DENY` | unset | Comma-separated addresses and ranges no outbound call reaches, like `203.0.113.7, 2001:db8::/32`: the server’s own public addresses, or a neighbour’s. Loopback, private, link-local and the other non-public ranges are always refused (see [Security and privacy](/docs/security-and-privacy/)). An entry that is not an address or a range stops the server at boot. | | `INBOX_UCP_LODGING` | off | `on` turns on [UCP Lodging](/docs/stays/#ucp-lodging-off-for-now) booking sessions, for rooms that confirm at once. A draft protocol, so off by default. Needs `INBOX_SECRET_KEY`. | A few commands run against the same database and exit. Run them from the directory the server runs in, or with `INBOX_DB` set (`node --env-file-if-exists=.env …` reads a `.env` when there is one), so they use its database: each says on stderr which database it used. Without `INBOX_DB` a command uses `./data/inbox.db` when that file is there, and refuses to run rather than create a new one.
```bash
node apps/inbox/dist/server.mjs create-owner-key laptop # prints a new owner API key (sdi_own_…) once
node apps/inbox/dist/server.mjs set-owner-email new@x.com # the owner signs in with this address now (lost the old one)
node apps/inbox/dist/server.mjs seed-demo # adds a demo bike shop if the instance is empty
node apps/inbox/dist/server.mjs seed-showcase # the same shop with a week of items, rules and hours
node apps/inbox/dist/server.mjs network-ping # reports to every network that is on now, not at the next hour
node apps/inbox/dist/server.mjs apply-plan plan.json # applies a setup plan (see the API); --dry-run says what it would do
node apps/inbox/dist/server.mjs backup backup.db # a full copy of the database while it serves; never over a file
node apps/inbox/dist/server.mjs check-db backup.db # whether a file is a sound inbox database, before a restore
```
Open `/login`: enter an address from `INBOX_OWNER_EMAIL`, then open the link you receive and press Sign in, or type its code; or paste an owner key. Put Caddy or nginx in front for TLS. Jobs run on a one-second loop inside the process. Email in arrives at `POST /v1/email/inbound` as raw MIME, with the shared secret from settings (`email.inboundSecret`) in the `X-Inbox-Email-Secret` header; point a Mailgun route, a Postmark or SES inbound webhook, or a forwarder at it. The machine-readable install guide at [/install.md](/install.md) has a systemd unit and a Caddy site block to copy. ### With Docker [Section titled “With Docker”](#with-docker) The image is `ghcr.io/surfingdogai/inbox`, for amd64 and arm64, published on every release. It does the same build and runs the one file as an unprivileged user (uid 10001):
```bash
docker run -d --name inbox -p 127.0.0.1:8787:8787 -v inbox-data:/opt/inbox/data ghcr.io/surfingdogai/inbox:0.2.0
curl -s localhost:8787/healthz
```
The SQLite file lives in the `inbox-data` volume, so the container can be replaced without losing anything. With no variables at all it answers the public API and the MCP server at `/mcp`. It answers on the server itself only (`127.0.0.1`), over plain HTTP: put a proxy with TLS in front, one that drops a `CF-Connecting-IP` the caller sent, as the inbox limits callers by it. Pass the variables from the table above with `-e` or a file, for a real instance at least these:
```bash
(umask 077; echo "INBOX_SECRET_KEY=$(openssl rand -base64 32)" > inbox.env) # keep a copy somewhere safe
docker run -d --name inbox --restart unless-stopped -p 127.0.0.1:8787:8787 \
-v inbox-data:/opt/inbox/data --env-file inbox.env \
-e INBOX_OWNER_EMAIL=you@yourbusiness.com -e INBOX_PUBLIC_URL=https://inbox.yourbusiness.com \
ghcr.io/surfingdogai/inbox:0.2.0
```
The commands above run inside the container, for example `docker exec inbox node /opt/inbox/server.mjs create-owner-key laptop`, and two more for backups: `backup ` and `check-db ` ([Backups](/docs/backups/)). A folder of the host mounted as the data folder must belong to uid 10001 (`sudo chown 10001:10001 `); started with `--user 0`, the image gives it to that user itself, then runs as it. [deploy/docker-compose.yml](https://github.com/surfingdogai/inbox/blob/main/deploy/docker-compose.yml) adds Caddy with HTTPS in front. `docker build -t surfingdog-inbox .` builds the same image from the repository. ### A public demo [Section titled “A public demo”](#a-public-demo) `INBOX_DEMO=1` turns an instance into a demo shop that anyone’s AI can book with, like the one on [Try it](/try/). Start it on an empty database: it fills itself with a bike workshop and a week of history. It never emails a customer. The only email it sends is a sign-in link or one summary a night, only to `INBOX_OWNER_EMAIL`, from `MAIL_FROM`. It never contacts a network or sends a webhook. Its rules confirm a small booking in a free slot inside the opening hours and accept a small order on their own; anything else waits for a person, and `/demo/live` says why. It limits what each caller, and everyone together, can send, and at 03:00 UTC it wipes itself and starts again, with its week of history dated as if it came in over the past days and marked example. `/demo/live` shows what arrives, with no names, addresses or messages. On a database that already holds anything it does nothing: no live view, no wipe, and the Node server won’t start. ## First calls [Section titled “First calls”](#first-calls) The examples hit demo.surfingdog.ai, our demo bike shop, the one on [Try it](/try/). It emails no one who uses it, is wiped every night, and its rules answer a booking in a free slot in a second or two. The dates below were worked out when this page was built. On your own instance, use its address. Read the manifest, then the services:
```bash
curl https://demo.surfingdog.ai/.well-known/agent-inbox.json
curl https://demo.surfingdog.ai/v1/services
```
Check availability for a service, here the `id` of Full service from that list (the window may span at most 14 days):
```bash
curl "https://demo.surfingdog.ai/v1/availability?service_id=&from=2026-11-21T00:00:00Z&to=2026-11-28T00:00:00Z"
```
Request a booking at one of those times. `endTime` may be left out: it is the start plus the service’s length. Generate a random idempotency key (a UUID) and keep it: the same key with the same body returns the same item (`200` with `Idempotent-Replayed: true`) instead of a second booking.
```bash
KEY=$(uuidgen) # keep it for a retry
curl -X POST https://demo.surfingdog.ai/v1/bookings \
-H 'content-type: application/json' -H "idempotency-key: $KEY" \
-d '{
"payload": {
"reservationFor": { "serviceId": "" },
"startTime": "2026-11-21T10:00:00Z"
},
"contact": { "name": "Rita", "email": "rita@example.com" }
}'
```
Full service has a price, so the first answer is `409 confirm_terms`: what your person confirms before it binds them (`details.summary`) and its fingerprint (`details.terms_sha`). Send the same request again, with the same key and `"terms_sha"` added, as an assistant does on its person’s yes. The answer is `201` with the item view (`view.item.state` is `requested`, and a second or two later the demo’s rules have confirmed it) and, because you have no account here, an `accessToken`. Keep it: it is the only way to read or cancel that item later. If the answer is lost, send the same request with the same key: a random key gets the same token back.
```bash
curl "https://demo.surfingdog.ai/v1/items/- " -H 'x-access-token: '
curl -X POST "https://demo.surfingdog.ai/v1/items/
- /cancel" \
-H 'content-type: application/json' -H 'x-access-token: ' -H "idempotency-key: $(uuidgen)" \
-d '{ "reason": "Found a closer workshop" }'
```
`service_id` and `access_token` may be spelt `serviceId` and `accessToken` too, in a body, a query or an MCP tool’s arguments. Send something wrong and the refusal is a problem document that names the fields:
```json
{
"type": "https://surfingdog.ai/problems/invalid_input",
"title": "Invalid input",
"status": 422,
"code": "invalid_input",
"detail": "Missing: payload.startTime. Add them and retry with the same idempotency key.",
"fields": [
{ "path": "payload.startTime", "problem": "missing", "message": "Invalid input: expected string, received undefined" }
]
}
```
On your own instance, the owner side of the same booking is one call with the owner key:
```bash
curl -X POST https:///v1/owner/items/
- /transitions \
-H "authorization: Bearer sdi_own_…" -H 'content-type: application/json' \
-d '{ "event": "confirm", "expected_version": 1 }'
```
The same operations are MCP tools at `/mcp` (public) and `/mcp/owner` (owner). See [Connect your AI](/docs/connect-your-ai/) and the [API](/docs/api/) page. Once your inbox answers at its own address and your website names it, [check your website for AI agents](/check): the check reads your site the way a customer’s assistant would, and shows the doors it finds.
# Receipts
> The signed proof an instance issues when it makes a promise — a booking confirmed, an order accepted or paid, a refund owed — when both sides change it, and when it closes; how an agent reads and verifies it, and how it counter-signs.
A **receipt** is the instance’s signed statement about a transaction: this booking was confirmed, this order was paid, this refund is owed by the 14th — a **promise** — then any change both sides agreed to it — an **amendment** — and later how it ended: the booking happened, the customer did not turn up, the order went out, the refund was paid on time — an **outcome**. It is a compact JWS, Ed25519, small enough to sit in a header, and it names the customer only by a pseudonym. Both sides can hold one, a network can count one, and nobody who sees one learns an address. Receipts are issued by the instance and counter-signed by the customer’s agent. They are how a network measures promise against delivery: what a business promised, and whether each promise was kept, broken or is still open. The format, claim by claim with what changed when, is the versioned spec: [Receipts](/spec/receipts/). [Check one](/spec/check/). ## When one is issued [Section titled “When one is issued”](#when-one-is-issued) Promises: | Item | Event | Receipt `knd` | | ------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------- | | booking | `confirm` (by the business, including a proposed time the customer agreed to by phone) or `accept` (customer takes a proposed time) | `confirmed` | | booking | the customer’s `accept` of a quote that creates a booking: the booking is created confirmed | `confirmed` | | order | `accept` | `accepted` | | order | the customer’s `accept` of a quote that creates an order: the order is created accepted | `accepted` | | order | `record_payment` | `paid` | Outcomes, `knd: "outcome"` with the code in `out` ([ADR-017](https://github.com/surfingdogai/inbox/blob/main/docs/adr/017-reputation-and-ranking.md) §3): | Item | Event | `out` | | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | | booking | `complete`, by you, or by the system `booking.autoCompleteHours` (48) after the end unless it was marked a no-show | `booking.completed` | | booking | `no_show` | `booking.no_show_customer` | | booking | `cancel_by_business` once confirmed | `booking.cancelled_by_business` | | booking | the customer’s `cancel` within the window, or their cancellation you record (`record_cancel`) when they asked within it; their withdrawal from a booking they paid for (`withdraw`, `record_withdrawal`), never late | `booking.cancelled_by_customer` | | booking | the customer’s cancellation after the window (`cancel_late`, or `record_cancel_late` when you record it and they asked after it), when `booking.lateCancellation` is `record` | `booking.cancelled_late_by_customer` | | order | `fulfil` | `order.fulfilled` | | order | your `cancel` once accepted, paid or not | `order.not_fulfilled` | | order | the customer’s `cancel` once accepted, their cancellation you record (`record_cancel`), or their withdrawal before it went out (`withdraw`, `record_withdrawal`) | `order.cancelled_by_customer` | | order | `payment_failed` | `order.payment_failed` | | order | `charge_back`, or `record_charge_back` on a completed order, unless a return of it is open or was refunded late | `order.charged_back` | | order | the system’s `lapse`, `orders.payDays` (14) after payment was requested and none came | `order.lapsed` | A no-show recorded by mistake, or a completion that should have been one, can be corrected once, until the booking would have completed on its own: the later receipt is the one a network keeps. After that the correction is no longer offered. Bookings and orders promised before the upgrade that brought outcomes are left as they were: never completed or lapsed by the system, no outcome recorded, closed by you. Declines, expiries, cancellations before anything was confirmed or accepted, quotes and messages close no promise and issue no outcome. An order the customer withdraws from after it went out stays `order.fulfilled`: its return is an item of its own, with receipts of its own (below). ### Changes and refunds (network rules version 6) [Section titled “Changes and refunds (network rules version 6)”](#changes-and-refunds-network-rules-version-6) These are the receipts [ADR-017](https://github.com/surfingdogai/inbox/blob/main/docs/adr/017-reputation-and-ranking.md)’s Amendment 3 adds, as network rules version 6. A network announces a rules version 15 days before it takes effect (at once while no other business is a member of it), and each network is sent them only once it takes version 6 (see [Networks](#networks)). | Item | Event | Receipt | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | | booking, order | `accept_change`: a change to a confirmed booking or an accepted order, accepted by the other side, a yes given by phone and recorded by a person included | `knd: "amended"`, one per change | | refund | its date is fixed: the refund is made owed at once (a withdrawal before the goods went out, a paid order or booking you cancel), approved with nothing to come back, or the goods are back (`goods_back`) | `typ: "refund"`, `knd: "accepted"` | | refund | `refund`, paid by its date (or before any date was fixed) | `refund.honoured` | | refund | `refund`, paid after its date | `refund.late` | | refund | the customer drops the return after its date was fixed (`record_cancel` when they tell you; their own `cancel` works only while the goods are still with them, before any date is fixed, and records nothing) | `refund.cancelled_by_customer` | A change that was proposed and then declined, withdrawn or left to lapse issues nothing: what was agreed stands. A return dropped before its date was fixed promised nothing and closes nothing. A refund’s receipts follow the order or booking it refunds: one of an order you wrote down yourself has none, and nor has one that owes nothing (the order was to be paid on delivery or on account). An amendment’s dates are the promise’s as every change up to it left them, and a change agreed in the same second as the one before it still sorts after it. `refund.honoured` counts as kept for the business, `refund.late` as broken; a refund promised and never paid is, for a network, a promise nobody closed. None of these is ever a mark on the customer: returning goods or withdrawing from a contract earns and costs a customer nothing, and a network judges a customer’s cancellation against the later of the latest agreed date and the last one the customer’s own assistant acknowledged, so a change nobody can show they agreed never makes it late. Without that acknowledgement a network honours at most three changes to one promise, none moving its date, or a booking’s end, more than 90 days from the first; the instance holds you to the same. The transition writes a job in the same batch as the event; the job signs within the second on a live instance, and `iat` is the time of the event itself. One receipt per item, kind, outcome and agreed change: a job that runs twice finds the row and stops. **Sandbox items never get one.** A receipt for a rehearsal would be a signed statement that something happened when nothing did. Bookings and orders you, your staff or a rule created keep the `confirmed` and `paid` receipts they always had and record no outcome and no change: the networks count what customers asked for. Two settings gate it. `INBOX_SECRET_KEY` seals the private key before it is stored, so an instance without one creates no key and issues nothing rather than keep a signing key in the clear. `INBOX_PUBLIC_URL` is the `iss` claim, and a job has no request to derive it from. Without either, the job records the reason and finishes; nothing is retried, and the owner’s Settings page says which one is missing. ## Reading one [Section titled “Reading one”](#reading-one) The receipts are on the item, for whoever may read it: the owner through `GET /v1/owner/items/{id}` or `get_item`, the customer through `GET /v1/items/{id}` or `get_item_status` with the access token they were given at creation.
```json
{
"receipts": [
{
"id": "01M34X9PAFXES35PXPY6QT944T",
"kind": "confirmed",
"jws": "eyJhbGciOiJFZERTQSIsInR5cCI6InNkaS1yZWNlaXB0K2p3cyIsImtpZCI6Ii4uLiJ9.eyJpc3MiOi4uLn0.…",
"payload": {
"iss": "https://inbox.oficinamare.pt",
"sub": "Zm9vYmFyYmF6cXV1eGZvb2JhcmJhenF1dXhmb29iYXJiYXo",
"itm": "01M34AVYNXTNSE2RC495H3W8QS",
"typ": "booking",
"knd": "confirmed",
"iat": 1790000461,
"nonce": "0123456789abcdef0123456789abcdef",
"amt": { "value": 4500, "currency": "EUR" }
},
"issued_at": "2026-09-22T09:01:01.000Z",
"acknowledged_at": null
}
]
}
```
`payload` is the decoded content of `jws`, for readers that do not want to decode it. Trust the JWS, not the copy. ## What is inside [Section titled “What is inside”](#what-is-inside) The header is `{"alg":"EdDSA","typ":"sdi-receipt+jws","kid":""}`. The claim names are frozen: a receipt outlives the software that wrote it. | Claim | Meaning | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `iss` | The issuing instance: its public origin, no trailing slash. Where the keys are. | | `sub` | Who it is about, as a pseudonym: `base64url(HMAC-SHA-256(pepper, identity))`, where the pepper is derived from the instance’s secret key and never leaves it. Two receipts for one customer on one instance share it; the same customer on another instance does not. Never an address, and never a bare hash of one. | | `itm` | The item’s id on the issuing instance. | | `typ` | The item type: `booking`, `order`, and since rules version 6 `refund` (a refund’s own receipts). | | `knd` | `confirmed`, `paid`, `accepted` or `outcome`, and since rules version 6 `amended`. A refund’s receipts are `accepted` (its promise) and `outcome`. | | `iat` | Issued at, Unix seconds: when the event that caused it happened. | | `nonce` | 128 bits of hex. A network deduplicates on `(iss, nonce)`, so a receipt shown twice counts once. | | `amt` | On a promise, the amount the item states, in minor units, when it states one. For a paid order it is the amount recorded as paid. Never derived, never summed. | | `pay` | How it was paid, when the instance knows (`card`, `transfer`…). | | `ver` | `2` on the receipts of bookings and orders a customer made (claims v2); absent on the others, which are v1. | | `out` | On an outcome, how the promise ended: one of the codes above. | | `ref` | On an outcome or an amendment, the `nonce` of the item’s earliest promise. | | `due` | When the item is due, Unix seconds: a booking’s start; an order’s delivery time, else 30 days (`orders.dueDays`) after it was accepted; a refund’s date to be paid by. A promise keeps the date it was made with; an amendment names the new one, and an outcome carries the latest amendment’s, else its promise’s. | | `end` | A booking’s end, likewise. | | `trm` | Rules version 6: the terms both sides agreed, as `base64url(HMAC-SHA-256(k, terms_sha))` — `terms_sha` is the fingerprint the customer confirmed, `k` a key the business derives for that offer alone and discloses only to settle a dispute, so nobody holding the receipt can test guessed terms against it. On an amendment always, on a booking’s or an order’s promise only when every network it is sent to takes version 6. Evidence for a dispute; a network never scores it. | | `acc` | Rules version 6, on an amendment: who accepted the change, `customer` or `business`. | | `aut` | `1` when nobody decided it: the system completed the booking, or a rule fired the transition. | | `per` | `[{"n": "", "p": ""}]`: each network’s presentation of the customer for this item, when their assistant presented one. Each network reads only its own entry. Never on an amendment or a refund’s receipts, which are about the business’s promise. | No customer name, no address, no line items. A receipt proves a transaction happened and what it was worth. It is not a copy of the order. ## Verifying one [Section titled “Verifying one”](#verifying-one) The public keys are in two places, identical: the manifest under `receipt_keys`, and `/.well-known/jwks.json` for anything that expects a JWKS. `kid` is the RFC 7638 thumbprint of the key, so it is derivable from the key itself. A retired key stays published so an old receipt still verifies. A business that moves its inbox can take its key along. A network keeps the record by the business’s address, and verifies each receipt against any key the manifest there publishes, so a copy opened without the key still verifies what was signed before and signs new receipts with a key of its own. With the key, the inbox it moves to signs with the same one. A download carries it only when the owner asks (`receipt_keys: true`, a switch in the owner app), in `receipt-keys.json`, beside the database and never inside it (`{"format": "surfingdog-receipt-keys/1", "active": kid, "keys": [{kid, jwk, created_at, retired_at}]}`): the key that signs now, its private half in the clear, and no retired one. `node server.mjs import-receipt-keys receipt-keys.json` on an inbox you run on Node checks it (its `kid` is its own thumbprint, and its private half signs what its public half verifies), seals it with that instance’s `INBOX_SECRET_KEY` and makes it the one that signs. Taking the same file in twice changes nothing. If a copy of the key may be in the wrong hands, make a new one: Settings → Networks, “Make a new receipt key”, or `POST /v1/owner/receipt-keys/rotate` (the owner in person only). The old key is retired: your inbox signs nothing more with it, and it stays published, so every receipt it signed still verifies. The published keys do not say when one was retired, so a network would still accept a receipt someone else signed with the old key. The verifier decides the algorithm, not the token: refuse anything whose header is not `EdDSA` and `sdi-receipt+jws`, find the key by `kid`, verify the signature over `.`, then read the claims. With the MIT `@surfingdog/sdk`:
```ts
import { verifyReceipt } from "@surfingdog/sdk";
const jwks = await (await fetch(`${inbox}/.well-known/jwks.json`)).json();
const { claims, sha } = await verifyReceipt(receipt.jws, jwks, { issuer: inbox }); // throws on any mismatch
```
It throws `ReceiptVerificationError` with the code a network would answer: `malformed`, `bad_alg`, `bad_typ`, `unknown_key`, `bad_signature`, `bad_payload` (claims a network would refuse), `wrong_issuer` or `not_yet`. Fetch the keys from the inbox you dealt with, not from the `iss` inside the receipt you are checking. ## Acknowledging one [Section titled “Acknowledging one”](#acknowledging-one) The customer’s agent counter-signs to say it holds the same receipt. From then on the receipt is *acked*: both sides hold it, and a network is told so rather than asked to guess. An unacked receipt is still a receipt, only weaker evidence. The acknowledgement is a compact JWS **by the agent’s own Ed25519 key**, which travels in the header:
```plaintext
header {"alg":"EdDSA","typ":"sdi-receipt-ack+jws","jwk":{"kty":"OKP","crv":"Ed25519","x":"…"}}
payload {"rcp":"01M34X9PAFXES35PXPY6QT944T","sha":"","iat":1790000500}
```
`rcp` is the receipt’s `id` from the item; `sha` is the base64url SHA-256 of the receipt’s `jws` string exactly as you received it, no padding — it is what lets a network that never sees the instance’s ids check that your acknowledgement is of this receipt and no other; `iat` is now, in seconds. You may add `pas`, the reference of the person’s pass (`sdpass1__`, never the pass itself), so the network knows whose acknowledgement it is; anything else in `pas` is refused. Acknowledge the outcome receipts too: an acknowledged completion counts in full where an automatic one is presumed. Send it to `POST /v1/items/{id}/receipt-ack` with the access token (in the body as `access_token`, as `?access_token=`, or as `X-Access-Token`), or call the `acknowledge_receipt` tool. You may include `receipt` (the JWS) if you want the instance to confirm it is the one it holds. With the SDK it is one call, and the key is one you keep:
```ts
import { generateAgentKey, signAck } from "@surfingdog/sdk";
const key = await generateAgentKey(); // once; store key.privateJwk
const counter_signature = await signAck({ receipt: receipt.jws, receiptId: receipt.id, key, passRef });
```
By hand, with WebCrypto alone:
```ts
const enc = new TextEncoder();
const b64u = (b: Uint8Array) => btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const { publicKey, privateKey } = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]);
const jwk = await crypto.subtle.exportKey("jwk", publicKey);
const header = b64u(enc.encode(JSON.stringify({ alg: "EdDSA", typ: "sdi-receipt-ack+jws", jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x } })));
const sha = b64u(new Uint8Array(await crypto.subtle.digest("SHA-256", enc.encode(receipt.jws))));
const payload = b64u(enc.encode(JSON.stringify({ rcp: receipt.id, sha, iat: Math.floor(Date.now() / 1000) })));
const sig = await crypto.subtle.sign({ name: "Ed25519" }, privateKey, enc.encode(`${header}.${payload}`));
const counter_signature = `${header}.${payload}.${b64u(new Uint8Array(sig))}`;
await fetch(`${iss}/v1/items/${itemId}/receipt-ack`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ counter_signature, access_token }),
});
```
The instance checks that the signature verifies against the key in the header, that `rcp` names a receipt on this item and `sha` is that receipt’s hash, that `iat` is no more than an hour old and no more than five minutes ahead, and keeps it. The response is the receipt with `acknowledged_at` set. **The first acknowledgement is the one both sides hold**: a second one, with the same key or another, is accepted and changes nothing. Keep the private key you signed with: a network weighs each acknowledgement by the key that signed it, and counts one from a verified agent above what the business says alone. Refusals are problem documents: `403` when the item is not yours, `404` when `rcp` is not on this item, `422` with a `reason` (`bad_signature`, `expired`, `not_yet`, `bad_alg`, `malformed`) when the acknowledgement itself is wrong. **An agent that signs its requests** (`sdi-agent/1`, with a key the person delegated to their pass: see [the agent guide](https://surfingdog.ai/for-agents.md)) can acknowledge without a counter-signature: it sends `receipt_id` in a signed request that carries the pass reference, in `Sdi-Pass` or the `pass` field. The inbox forwards that signature to the person’s network with the receipt’s `sha`, and the network checks it again under the delegated key. The inbox never forwards a signature whose signed parts hold a secret — an access token in the URL, another pass in `Sdi-Pass` — and says `carries_secret` instead; send the token in `X-Access-Token`. ## Networks [Section titled “Networks”](#networks) The instance publishes every receipt to each network switched on in Settings → Networks — once when issued, again once acknowledged — with `POST /v1/receipts` and the body `{"receipt": "", "ack": ""}` (`ack` only when there is one). The manifest names the networks an instance publishes to under `review_services`. The network answers `{"ok": true, "state": "issued" | "acknowledged", "duplicate": false}`. Each network is kept track of on its own, receipt by receipt, so nothing is lost to one that is down: a receipt waits for a network that does not answer, or does not know the instance yet, and goes out when it does. A network switched on later is sent every receipt issued before it, oldest first, up to a thousand an hour; it counts a completion whose promise reached it more than a day late at half, so switching on early is what counts. Which receipts a network gets depends on the rules it applies, which the instance reads once a day from its `GET /v1/ranking`: from rules version 3 — in force, or announced in `next` — it gets every receipt, acceptances and outcomes included; before that, the `confirmed` and `paid` promises it has always had, and the rest waits until it moves on. From rules version 6, in force or announced, it also gets amendments and refunds’ receipts, which wait until then. A booking or an order whose promise both sides changed goes to a network only once version 6 is **in force** there — its promise, each amendment and its outcome, in that order — since a network on older rules would hold the business to the date first agreed; for the same reason a change can be made only while every network that holds the promise, or will be sent it, has version 6 in force (with none switched on, at once). An outcome goes after its promise and amendments, and a network that stopped taking receipts in Settings still gets the amendments and outcomes of the promises it was sent. The instance tries again when the network answers `404`, `408`, `425`, `429`, a redirect or a server error, or a problem document whose `code` is `unknown_key`, `unknown_ref`, `unknown_instance` or `unknown_issuer`; any other refusal is the network’s verdict on that receipt, recorded once and not sent again. Settings → Networks shows how many receipts each network has. `per` in a receipt names the presentation a network made of the customer for that item, when their assistant presented a pass: that is how a network ties the item to its person, and each network reads only its own entry. A network that never saw the person on that item counts the receipt without one. The network believes nothing it is sent. It finds the business by `iss`, takes the keys from the manifest it fetched from that domain itself, verifies the receipt, verifies the acknowledgement against the key in its header and the receipt’s `sha`, and counts one receipt per `(issuer, nonce)`. It refuses a receipt from a domain it has not listed (`404`), one signed by a key the manifest does not publish (`422 unknown_key`, after refreshing its copy), an outcome whose `ref` it does not hold yet (`422 unknown_ref`, sent again later), a forgery (`422 bad_signature`) and one dated more than five minutes ahead (`422 not_yet`). It stores the receipt whole, with the acknowledgement when there is one: the pseudonym, the type, the kind, when, and the amount and payment method when the receipt carries them — never an address, never a name — and keeps every one. A directory listing shows how many promises a business has made and how many were counter-signed, and how they ended; the amounts are never published. Its page on the network’s site and its listing for agents show a **track record**, from the count the network makes each night: promises kept, broken (a refund paid late, and a promise nobody closed, are broken) and still open, over which dates, with the rules it was counted under. It shows once at least 5 promises have closed, and not in the 30 days after the business joins; until then the page says how many have closed so far. Until at least 3 different customers have confirmed kept promises of it, a record is **self-reported**: the business’s own word, shown as its counts, with no score and no count of customers, and not ranked. A promise is confirmed by the customer’s side when the network can check the customer’s acknowledgement, through the key of an assistant platform it recognises or of a customer whose record is established; today there is neither, so every track record is self-reported. Any record says how many of its closed promises the customer’s side confirmed, when it confirmed any; one that is not self-reported also says from how many customers they came, and shows only once they are at least 3. Any service that speaks this one endpoint can take an instance’s receipts, and an instance can publish to several; `https://network.surfingdog.ai` is only the default. ## For implementers [Section titled “For implementers”](#for-implementers) `packages/spec/vectors/receipts.json` in the repository (MIT) holds fixed keys, the `sub` derivation with its inputs, two receipts whose JWS an implementation must reproduce byte for byte, a valid acknowledgement, and every refusal with the error code it must raise. `packages/spec/vectors/receipts-v2.json` does the same for claims v2: a promise and its outcome for each of the eleven outcomes, an acknowledgement with `pas`, the refused claims, and every path through the booking and order state machines with the outcome it records. `packages/spec/vectors/receipts-v6.json` does the same for rules version 6: amended bookings and orders and the outcomes that read their latest dates, a refund’s promise and each of its outcomes, what a reader on rules 5 keeps of each (it ignores `trm` and `acc`, however they look, and refuses refunds and amendments), the claims still refused and what a reader on rules 5 keeps of those, an acknowledgement of an amendment, the `order.refund_refused` report, and every path through the refund machine. A verifier in any language is right when it agrees with those files. ## As events [Section titled “As events”](#as-events) A receipt being issued, and being counter-signed, are events on the item like any transition: `booking.receipt_issued`, `booking.receipt_acknowledged`, `order.receipt_issued`, `order.receipt_acknowledged`, and a refund’s `refund.receipt_issued` and `refund.receipt_acknowledged`. They appear in `GET /v1/owner/events` and reach your [webhooks](/docs/webhooks/) under the same subscriptions (`order.*` includes them); in the full payload style, `data.receipt` carries the receipt. The issue event’s id is the receipt’s id; the acknowledgement’s is that id with `:ack`. ## Keys [Section titled “Keys”](#keys) One key per instance, generated on first use, private half sealed by `INBOX_SECRET_KEY` (AES-256-GCM) and never readable back through any door. If the secret key is changed the sealed private key can no longer be opened and the instance stops issuing until it is restored — so treat `INBOX_SECRET_KEY` as the thing it is, and add keys in front of it for rotation rather than replacing it. The pseudonym pepper comes from the same secret and cannot rotate at all without severing the link between receipts already in the world and the customers they are about. [ADR-016](https://github.com/surfingdogai/inbox/blob/main/docs/adr/016-receipts.md) has the reasoning.
# Run it yourself
> One command on a server of your own, the Docker image on a platform you use, or Cloudflare Workers. Then updates that undo themselves if they fail.
The inbox is open source (MIT), and the same software runs three ways. Each gives the business its own inbox at its own address, such as `inbox.yourbusiness.com`. | | What you need | What you get | | -------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------- | | [One command on your server](#one-command-on-your-server) | An Ubuntu or Debian server, and one DNS record | HTTPS, updates that roll back, a backup every night | | [Docker, or a platform you use](#docker-or-a-platform-you-use) | Docker, or Coolify, Fly.io, Render, Railway or a DigitalOcean Droplet | The image `ghcr.io/surfingdogai/inbox`, amd64 and arm64 | | [Cloudflare Workers](#cloudflare-workers) | A Cloudflare account | D1, a queue and a cron, nothing to keep running | The inbox keeps its data in one SQLite file (on Cloudflare, a D1 database), so every way runs one copy of it, on a disk that lasts. ## One command on your server [Section titled “One command on your server”](#one-command-on-your-server) On a fresh Ubuntu (22.04 or newer) or Debian (12 or newer) server, amd64 or arm64, with 512 MB of memory or more: 1. Where your domain’s DNS is, add an **A record** for the inbox’s address, such as `inbox.yourbusiness.com`, pointing at the server’s IP address. If the DNS is at Cloudflare, leave the record **DNS only** (grey cloud), so the server can get its own certificate. 2. On the server:
```bash
curl -fsSL https://surfingdog.ai/install.sh | sudo sh
```
It asks two things, the address and the owner’s email, then: * before it installs anything, checks that ports 80 and 443 are free and that the address has a DNS record, and says what to change if not; * installs Docker from Docker’s own repository, if it is missing, and checks that every address the name points at leads to this server, not through Cloudflare’s proxy; * writes `/opt/inbox`: the settings in `.env` (readable by root only, with a secret key it generates), a Compose file, and Caddy’s site file. The data goes in `/opt/inbox/data`; * starts the inbox behind [Caddy](https://caddyserver.com), which gets the HTTPS certificate from Let’s Encrypt and renews it; * installs the `inbox` command, and a backup every night that keeps the last seven. It sends nothing about you or your server anywhere. It downloads Docker’s packages (only when Docker is missing), the inbox’s image and Caddy’s, and reads the newest release’s number from GitHub. The script is one file you can read first: [install.sh](/install.sh), its source in [deploy/](https://github.com/surfingdogai/inbox/tree/main/deploy). Its checksum beside it, [install.sh.sha256](/install.sh.sha256), only shows the download was not cut short, as it comes from the same place. To check it against a source of its own, take it from the newest release on GitHub, with the release’s checksums:
```bash
curl -fsSLO https://github.com/surfingdogai/inbox/releases/latest/download/install.sh
curl -fsSL https://github.com/surfingdogai/inbox/releases/latest/download/SHA256SUMS | grep ' install.sh$' | sha256sum -c
sudo sh install.sh
```
With the GitHub CLI, `gh attestation verify install.sh -R surfingdogai/inbox` also checks that the release workflow built it. Without questions, for a script or your AI:
```bash
curl -fsSL https://surfingdog.ai/install.sh | sudo sh -s -- --domain inbox.yourbusiness.com --email you@yourbusiness.com --yes
```
Run it again whenever you like. It keeps the data, the secret key and the version, and changes only what you change: `--domain` for a new address, `--email` for the owner’s address (which lets only the first account in), `--version` for a release other than the newest. `--help` lists the rest. ### Then [Section titled “Then”](#then) 1. **Sign in.** The installer’s last lines hold your first sign-in: a link, and a code for `https://inbox.yourbusiness.com/login`, each good once, for 15 minutes. Until email is set up, `sudo inbox sign-in` gives a new one. 2. **Email.** `sudo inbox email` chooses how the inbox sends: through Cloudflare Email Service or Resend, from an address on your domain. Email in is a separate step: [install.md](/install.md), Step 2. 3. **The rest**, from your business’s hours to its own site pointing at the inbox: give your AI the line the installer printed ([install.md](/install.md) from Step 1), or follow it yourself. Keep a copy of the inbox’s secret key somewhere safe, apart from the server (`sudo grep INBOX_SECRET_KEY /opt/inbox/.env` shows it). It opens what the database keeps sealed, and a backup restores fully only with it. ### The inbox command [Section titled “The inbox command”](#the-inbox-command)
```bash
sudo inbox status # what runs, its version and health, its backups, whether email is set up
sudo inbox update # the newest release; `sudo inbox update 0.2.0` for that one
sudo inbox rollback # the previous version and its data (what came in since is lost)
sudo inbox backup # a full copy of the database now, in /opt/inbox/data/backups
sudo inbox restore # replace the inbox with that backup
sudo inbox sign-in # a sign-in code and link, while email is not set up
sudo inbox owner-key laptop # a new owner API key, shown once
sudo inbox email # how the inbox sends email
sudo inbox restart # start it again, with what you changed in /opt/inbox/.env
sudo inbox logs [caddy] # the inbox's log, or Caddy's
```
### Updates [Section titled “Updates”](#updates) `sudo inbox update` downloads the new version while the inbox keeps serving, with the release’s own Compose file, Caddy’s file and `inbox` command, checked against its `SHA256SUMS`. Then it stops the inbox, makes a backup, and starts the new version, which updates the database itself. If it does not answer within three minutes, the update is undone: the previous version comes back on that backup, so nothing is lost. Its log is kept in `/opt/inbox/state`. A version that started and stopped again may have sent an email or a webhook in between, which the previous version can then send a second time. Each release’s notes are on [GitHub](https://github.com/surfingdogai/inbox/releases); `sudo inbox status` says when a newer one is out. The last five backups made before an update are kept. It never goes to an older version: that is `sudo inbox rollback`, which puts back the data from before the update too. ### Behind a proxy you already run [Section titled “Behind a proxy you already run”](#behind-a-proxy-you-already-run) The installer’s Caddy needs ports 80 and 443. If the server already serves websites through nginx, Apache or a Caddy of its own, the installer stops and names it, before it installs Docker or writes anything. Run the inbox with Docker on its own and point your proxy at it instead. The settings go in a file of their own, as in [deploy/.env.example](https://github.com/surfingdogai/inbox/blob/main/deploy/.env.example), and the data folder belongs to the image’s user:
```bash
sudo mkdir -p /opt/inbox/data && sudo chown 10001:10001 /opt/inbox/data
sudo docker run -d --name inbox --restart unless-stopped -p 127.0.0.1:8787:8787 \
-v /opt/inbox/data:/opt/inbox/data --env-file /opt/inbox/.env \
-e INBOX_PUBLIC_URL=https://inbox.yourbusiness.com ghcr.io/surfingdogai/inbox:0.2.0
```
Have your proxy send `inbox.yourbusiness.com` to `127.0.0.1:8787` and drop any `CF-Connecting-IP` a caller sends, as the [install guide’s Caddy block](/install.md) does: the inbox limits callers by it when it is there. Updates are then `docker pull` and a new container, with a backup first ([Backups](/docs/backups/)). ### Taking it down [Section titled “Taking it down”](#taking-it-down) `cd /opt/inbox && sudo docker compose down` stops it and keeps the data. To remove it for good, after a last backup copied elsewhere: `sudo systemctl disable --now inbox-backup.timer`, then remove `/opt/inbox`, `/usr/local/bin/inbox` and the two `inbox-backup` units in `/etc/systemd/system`. ## Docker, or a platform you use [Section titled “Docker, or a platform you use”](#docker-or-a-platform-you-use) The image is `ghcr.io/surfingdogai/inbox`, for amd64 and arm64, built and smoke-tested by GitHub Actions on every release. It runs as an unprivileged user (uid 10001), keeps the database in `/opt/inbox/data`, and has a health check on `/healthz`. Tags: the version (`0.1.0`), its minor line (`0.1`) and `latest`. Pin the version: a container started again on `latest` can move to a newer one, which updates the database with no backup first. By hand, it answers on the server itself only, over plain HTTP: put a proxy with TLS in front, one that drops a `CF-Connecting-IP` the caller sent (the inbox limits callers by it), or take the Compose file below, which has Caddy. The key goes in a file you keep a copy of:
```bash
(umask 077; echo "INBOX_SECRET_KEY=$(openssl rand -base64 32)" > inbox.env) # keep a copy somewhere safe
docker run -d --name inbox --restart unless-stopped -p 127.0.0.1:8787:8787 \
-v inbox-data:/opt/inbox/data --env-file inbox.env \
-e INBOX_OWNER_EMAIL=you@yourbusiness.com -e INBOX_PUBLIC_URL=https://inbox.yourbusiness.com \
ghcr.io/surfingdogai/inbox:0.2.0
```
Every setting is in the [Quickstart](/docs/quickstart/#your-own-server). Commands run inside the container: `docker exec inbox node /opt/inbox/server.mjs create-owner-key laptop`. Behind a platform’s proxy that passes a `CF-Connecting-IP` the caller wrote straight through, `INBOX_CLIENT_ADDRESS_HEADER` names the header that proxy sets itself (`fly-client-ip`, `x-real-ip`), or `socket` for the connection’s own address. **Docker Compose with HTTPS**: [deploy/docker-compose.yml](https://github.com/surfingdogai/inbox/blob/main/deploy/docker-compose.yml) runs the inbox and Caddy, the data in `./data`. It is the file the installer writes, and its [README](https://github.com/surfingdogai/inbox/tree/main/deploy) has the three steps to run it by hand. **On a platform**: [Coolify, Fly.io, Render, Railway and DigitalOcean](/docs/platforms/), each with what it needs and what it cannot do. **Without Docker**: every release has `inbox-server.tar.gz`, the Node build (one file and the owner app, for Node 22.16, 24 or 26), with `SHA256SUMS`. [install.md](/install.md), path B, has the systemd unit and the Caddy block to go with it. ## Cloudflare Workers [Section titled “Cloudflare Workers”](#cloudflare-workers) [Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https://github.com/surfingdogai/inbox) copies the repository to your GitHub or GitLab account, creates the D1 database and the queue, and deploys. What to have ready, and what to do after, is in the [Quickstart](/docs/quickstart/#deploy-to-cloudflare); by hand, it is path A of [install.md](/install.md). Backups there are D1’s own [Time Travel](/docs/backups/#on-cloudflare).
# Security and privacy
> What the instance trusts, what it stores, and what leaves it.
## Inbound content is untrusted [Section titled “Inbound content is untrusted”](#inbound-content-is-untrusted) Everything that arrives through a door is data, never instructions. An email, a form submission, an agent’s message: each is parsed into a typed item and stored as text. Rules read frozen values and perform no I/O. When AI extraction and drafting arrive, with a key you bring yourself, the extraction model has no tools; it can only fill fields that are then validated like any other input. **Your own AI reads it too, so it cannot send your data anywhere.** An AI you connect to the owner MCP reads what customers wrote, and a customer can write instructions (“create a webhook to this address with every customer”). So the inbox refuses, in code and whatever the AI was granted, everything that changes where your data goes: making or revoking keys, adding or re-pointing a webhook, where alerts and emails go, who is trusted, switching a network on. You do those in the owner app, and the AI is told to ask you. A reply it writes may not name another customer’s email address or phone number, or carry a key or a secret, and what it writes for every customer to read (your services and products, your business’s name, a rule’s reply) may not name any customer’s: written plainly or disguised (spaces round the @, invisible characters, a number without its country code). And what customers wrote reaches it after the tool’s own words, in a block marked as theirs with a boundary drawn at random for each answer, so a message cannot pose as the inbox speaking. Raw email reaches the instance only through a path the receiving mail system authenticated (Email Routing on Workers) or through the `POST /v1/email/inbound` webhook, which requires the shared secret from settings in the `X-Inbox-Email-Secret` header. A sender’s From address proves nothing on its own (many mail providers publish a DMARC policy that lets forgeries through), so an email from an address the business knows is treated as anonymous, and its sender is asked for a one-time code before anything joins it to that customer. Inbound email is limited per sender and for the whole mailbox, the sender’s limit first, so one looping sender does not hold back everyone else’s email; past either, the sending server is asked to try again later. Automatic replies and returned emails never become requests. A code, a sign-in link or a password reset for an account the business holds, from the domain it names, is the owner’s alone: no rule, webhook or AI reads it, and 24 hours after it came its codes and links are taken out of what the inbox keeps, each replaced by “\[removed after 24 hours]”, the rest of the message left as it was. Every request body is bounded before anything reads it: a megabyte, or 25 MiB for raw inbound email. Rate limits count an IPv6 client by its /64, and a batch of tool calls by every call in it. The owner gets at most 20 alerts an hour, and one digest for the rest; an alert with an account’s code or sign-in link is never held for the digest, up to 30 of those an hour and 5 from any one sender’s domain. When the instance fetches a remote document (an OAuth Client ID Metadata Document, a product feed) it refuses IP literals, local and internal hostnames, redirects to another host, large bodies and slow servers, and checks every redirect again. A network is called only at an https address on a public host, port 443, with no path: that is checked when the owner adds it and again on every call, with a five-second limit per call and no redirects followed. What those calls carry is listed below, and nothing else. Those are checks on the name, and a public name can point anywhere. So on Node, every call to an address someone else chose (a webhook, a feed, a client document, a platform’s key directory, a network) resolves the name first and is refused when any address it resolves to is loopback, private, link-local, shared (100.64.0.0/10), unique local (fc00::/7), IPv4-mapped or otherwise reserved, or in a range the operator closed with `INBOX_OUTBOUND_DENY` (the server’s own public address, say). An address under NAT64’s 64:ff9b::/96, which DNS64 gives an IPv6-only server for every name with only an IPv4 address, is judged as the IPv4 address it carries. The connection goes to the address that was checked, so the name cannot be made to answer differently in between. A webhook the owner allowed onto a private address (`integrations.webhooks.allowPrivateTargets`, for a machine of their own) lifts the built-in ranges, never the operator’s, which hold for an IPv4 address written as IPv6 too; an address that reaches IPv4 through a tunnel (6to4, Teredo, the IPv4-compatible form) is not called even then. On Workers, the runtime resolves names itself and gives code no way to pin an address; there the name rules are what hold. A Worker’s `fetch` leaves from Cloudflare’s network, not from a machine on yours, and takes no IP address in a URL. ## What reaches a network [Section titled “What reaches a network”](#what-reaches-a-network) An instance can report to several networks, and each one lists it in its own directory. A hosted inbox joins network.surfingdog.ai the day it goes live; one you run yourself asks during setup. The owner switches any network off, or on, in Settings → Networks, and a network that is off receives nothing. Message text, names, phone numbers, postal addresses, line items and payment references never leave the instance. What can leave, to each network that is on: * **activity counts**: a ping every hour with the software version, the runtime, and the number of items created in the last 24 hours per type (`share.counts: false` sends none). With `INBOX_SECRET_KEY` set, the ping is signed with the instance’s receipt key, so only this instance learns its own standing from the answer; * **receipts**: every receipt the instance issues (a booking confirmed, an order accepted or paid, and how each ended) and the customer agent’s counter-signature when one arrives (`share.receipts: false` sends none, except the outcomes of promises the network already holds). A receipt carries the issuer, the item’s id on the instance, its type, what it attests, when, a nonce, the customer’s pseudonym (an HMAC under a key only this instance holds, never an address), the due date, and, when the item states them, the amount and the payment method; and, when the customer’s assistant presented them, the id of each network’s presentation (every network the receipt goes to sees them all; each counts only its own). A counter-signature carries the agent’s public key, the same at every business where that agent uses it, so the receipts it counter-signed can be linked to each other, and may name the person’s pass by reference. The fields are on the [Receipts](/docs/receipts/) page; * **a customer’s email, at first contact**: when a customer’s booking or order has an email and their assistant carried no pass, the instance sends the network the email address as the customer typed it, the item’s id as a request id, and a label for the assistant (with its key’s thumbprint and platform when it signed), so the network can give the customer a key. Only networks with “Give first-time customers a key” on (`issue`, on by default) get this, and only once the network has verified the instance (it read the instance’s manifest and took its ping; Settings → Networks says whether it has), except the default network, `network.surfingdog.ai`; switch it off per network. The network keeps a keyed hash of the address, and the address itself only sealed, until it has emailed the customer their key (7 days at most); * **what an assistant carries**: a pass or a key the customer’s assistant presents goes to the network that issued it, and to no other, with the customer’s email address (normalised) when the request has one and the network may receive addresses (the same rule as above), so the network can say whether the address is the person’s. A pass reference goes only inside the assistant’s own signature, forwarded as it was made: the method, host, path and query of the request, a hash of its body, the assistant’s public key, the pass reference and the signature. A signature that would carry a secret to the network, or that covers any header beyond those and `Content-Type`, is not forwarded; * **the public profile** in the manifest, which a network reads from the instance’s own domain when it verifies it, and lists in its directory. * **your products**, while “Your products on networks” is on (Settings → Networks; on unless you switch it off, and only while the business is in the directory): each product on sale that you have not kept off networks, as your own doors already show it, with its id, SKU, name, description, price, barcode, brand, part number, condition, its link on your own site and its picture’s link, and whether it is in stock, out of stock or on sale with its stock not counted; never a stock count, your lowest prices or where it was imported from. A network reads them from `GET /v1/catalogue/changes`, each page signed with the receipt key; without `INBOX_SECRET_KEY` there is no key and nothing is served. The signed hourly ping adds how many products are listed, when they last changed and a revision; switching it on or off, for every network or for one (“List my products here”), sends that network one signed request. An inbox updated from a version before this lists its products from then on, unless it is switched off. A test item (test mode, the `x-sandbox: 1` header or a `sandbox.` host) reaches no network at all: no key is asked for, no pass is presented, no receipt is signed, and it counts in no hourly count. **A customer who stops it.** The email that carries a first-time customer’s code links to a page, signed for them, where they can switch booking networks off for themselves; the owner can do it for them too (the owner app, `POST /v1/owner/customers/{party_id}/networks-off`, or the owner’s AI with `stop_customer_networks`). From then on nothing more about that customer reaches any network: no first contact, no presentation of a pass, no receipt or acknowledgement of theirs (a receipt still waiting for a network is withheld, and a network switched on later is never sent one), no code; nothing a network said about them is kept or read, so a rule that reads a standing treats them as it treats anyone no network knows. Their bookings, orders and emails are unchanged, and a pass their assistant carries still recognises them, by its hash, without a network call. The stop is written on every record that is them (every one with their email address; for a customer who gave only a phone number, every record with that number and no email address), and as fingerprints (SHA-256) of their email address (of their phone number only when they gave no email), so their next request is stopped before any network is called, and so is one from another address of theirs once a request their pass recognised, or a one-time code, showed it is theirs. Someone who gave their own email address beside a phone they share is never stopped by it. What a network already has stays with it: the network protocol has no call yet to ask a network to unlink or erase a person, so the owner sees, per network, how many receipts about them it took and how many of their promises it holds open (it records each as unclosed nine days after it was due), and the page names each network with its address, to ask it. What the instance keeps of this: a network’s answer about a person (a pairwise id for this business, and the standing it gave) on the item and the customer; SHA-256 hashes of passes, to recognise one again; the network’s answers for an hour (a day for “already known”), keyed by hashes; nothing of a key. A first-time customer’s key is sealed with `INBOX_SECRET_KEY` until it is emailed to them, alone, a day after their first booking or order, with a link, signed for them, to a page on the instance that names the networks, says what they keep, and lets them stop it for themselves (the same page, without the button, is at `/c/privacy`); the mail log keeps that email with the key cut out, and their first pass so the item’s creator can collect it again from the status door; both are deleted after seven days at most, and no door shows either to the business. Every network is run by someone, and keeps what it receives under its own rules; add only networks you trust with the list above. The network at `network.surfingdog.ai` keeps the hourly pings, with the address they came from, for 90 days; every receipt, counter-signature and presentation for good, so a record is never lost; a line a day of what its own reads found at each door a business publishes, for good (a site that opts out at surfingdog.ai/bot has it deleted); a person’s email only as a keyed hash, and sealed until their key email goes (7 days at most); the words of the searches AI agents send it, as typed, with email addresses, phone numbers and card and bank numbers taken out, with no time limit and never who sent them; and secrets only as hashes. Its directory shows a business’s listing and its counts, never a receipt, an amount, a pseudonym or anything about a customer, and a business sees a person’s standing only when that person’s assistant presents their pass. The [privacy page](https://surfingdog.ai/privacy) has the whole list. **Emails the instance sends.** Every email, to a customer or to the owner, is kept in the instance’s database with its subject, its text and what became of it (sent, retrying with the last error, failed, or not sent and why), so the owner can see that an email did not go out. The text is kept with each Accept, Decline or other answer link cut short: what the owner’s app, `get_item` or a copy of the database shows cannot answer for the customer, and only the email the customer got carries the whole link, made again with `INBOX_SECRET_KEY` when it is sent. An instance with no mail service writes each email to its log instead and records it as not sent (`no_service`), never as sent. To thread a customer’s reply onto its item without writing a `Message-ID` (Cloudflare Email Service writes that header itself), each item has a random anchor id, sent first in the `References` of every email about it; the instance keeps the anchor, each email’s own id and the id the mail service returned, and nothing else about the customer’s mailbox. These rows hold personal data, like the item they belong to, and are kept until the owner erases the customer: there is no automatic cut. Every email asks the recipient’s mailbox not to answer it by itself (`X-Auto-Response-Suppress`), and one that no person at the business caused says so (`Auto-Submitted`, where the mail service accepts it). An out-of-office or a returned email that arrives is never answered: it is kept as a note on the item it answers, or as a notice (an email from a system that answers no conversation, which only a person at the business reads, and nothing answers). The job log says whom an email went to (the customer, the owner), not their address; an error a mail service gave is kept as it said it, until the customer is erased. **One customer’s data.** The owner can export everything the instance holds about one customer, as a JSON file (the owner app, `GET /v1/owner/customers/{party_id}/export`, or `export_customer`): the records that are them (every one with their email address), their email addresses and phone numbers, network ids, and every item with its history, conversation, emails and receipts. The owner can erase a customer (the owner app or `POST /v1/owner/customers/{party_id}/erase`, after a confirm step; the owner’s AI cannot, and a key only with `customers:erase`). Erasing rewrites their names, email addresses, phone numbers, postal addresses, message text, what they named in their own words (a quote request, an order line no product of the catalogue names), the business’s notes and the emails about them to a placeholder, with the errors a mail service or a webhook’s receiver gave about them, and the address that named them as the author of what they wrote by email; merges the search index so none of their words stay in it; drops their contacts, identities, network ids, codes, answer links and stored answers, and keeps the structure: item types, states, times, amounts, the sequence of events, and the receipts, which are signed statements that carry no name, address or text (only the keyed pseudonym and presentation ids below). It reaches every record with their email address (for a customer who gave only a phone number, every record with that number and no email address), and no one else’s: someone who gave their own email address beside a shared phone is never reached by the phone. It also stops booking networks for them for good, and keeps only the fingerprint of their email address for that. Two things it leaves to expire, within a day: a network’s answers the instance cached (keyed by hashes), and rate limits (keyed by the caller’s network address). Should they write again, to one of the business’s old emails, that starts a new conversation the business can answer, and an automatic reply to one is kept as a notice: nothing is written back onto what was erased. It cannot be undone. Pseudonyms on the network are HMACs with a server-held secret, never a bare hash of an email or phone number. A standing is a record the network shows, not a decision it makes: each business’s own rules decide what a customer’s record earns them, and a rule that reads one may only speed things up or ask a person, never refuse. ## Signed requests [Section titled “Signed requests”](#signed-requests) An assistant may sign its requests (`sdi-agent/1`, HTTP Message Signatures, Web Bot Auth compatible). The instance verifies the signature against the key in `Sdi-Agent-Key`, or the key a platform’s directory lists (fetched over https from a public host, cached, one new platform per address a minute), checks that it covers the method, host, path, query, body digest and the passes carried, and that it was made in the last five minutes. A signature that does not verify is not an error: the request is served as unsigned, and `Sdi-Signature` says why. A signature on a request that changes something is kept, as a hash, until it expires, so a copy of the request is refused unless it is a retry from the same client with the same idempotency key. ## Keys and tokens are hashed [Section titled “Keys and tokens are hashed”](#keys-and-tokens-are-hashed) * Owner API keys (`sdi_own_…`) and agent keys (`sdi_agent_…`) are stored as SHA-256 hashes with a short prefix for identification. The key is shown once, when it is created. * OAuth access tokens (`sdi_at_…`, one hour) and refresh tokens (`sdi_rt_…`, thirty days) are opaque, hashed, and rotate on every refresh. A refresh token presented twice revokes the whole token family. * An AI app gets in only through the consent screen, with the owner signed in. The screen shows where the app’s access goes, says when its name is self-chosen, and warns when a name claims a known assistant that the destination doesn’t belong to. An Allow posted from another site is refused. The owner is emailed each time an app is allowed, and disconnects any app in Settings → Keys, which revokes its tokens at once. * A sign-in email carries a link and a six-digit code. Both are hashed, single-use and expire in fifteen minutes. Opening the link changes nothing: it shows a page whose button signs in, so a mail scanner that opens every link uses up none. A code stops after five wrong tries, and an address gets twenty tries a day. Neither the link nor the code is written to a log when an email fails to send; only an instance with no address to send from writes the whole email to its log. Sessions are hashed and expire after thirty days. * Changing the address the owner signs in with needs a fresh code sent to the address they have now, and a link to the new one, confirmed while signed in. The old address is told, with a link that puts it back for seven days, signs out every session and cuts off every key made in Settings and every AI app connected; when that notice cannot be sent, the change does not stand. * Receipts are signed with an Ed25519 key generated on the instance; the private half is sealed by `INBOX_SECRET_KEY` before it is stored, and the public half is published in the manifest and at `/.well-known/jwks.json`. A retired key stays published so old receipts still verify. Without `INBOX_SECRET_KEY` no key is created and no receipt is issued, rather than storing a signing key in the clear. ## Same-origin writes for cookies [Section titled “Same-origin writes for cookies”](#same-origin-writes-for-cookies) A session cookie only writes from the instance’s own origin: cross-site requests that carry a cookie are refused with `403` on anything but `GET`. API keys and OAuth tokens carry no ambient authority, so they are checked wherever they are sent. Cookies are `HttpOnly` and `SameSite=Lax`; over HTTPS the session cookie is `__Host-sdi_session`, so it is `Secure`, kept to the instance’s own host, and cannot be set by another host under the same domain. ## Small blast radius by design [Section titled “Small blast radius by design”](#small-blast-radius-by-design) One business is one database. Every write is a single batch of precomputed statements, with unique constraints and compare-and-set versions deciding races; there are no interactive transactions anywhere. Raw email is not kept: an email’s text, without its quoted history, is kept on its item, and its attachments are not kept. Batches stay small, and heavy work (email parsing, extraction, rule fan-out) runs in job consumers rather than in the request. ## Reporting [Section titled “Reporting”](#reporting) Security issues in the Inbox itself: email , as [surfingdog.ai/.well-known/security.txt](https://surfingdog.ai/.well-known/security.txt) says, rather than opening a public issue. Every inbox publishes its own `/.well-known/security.txt` too, for a problem with that one business’s inbox. It names the contact the owner sets in Settings → Keys, or the inbox’s own message form (`/p/message`) when none is set.
# Self-hosted vs hosted
> The same software either way. What you run, what we run, and what each one includes.
The inbox is one codebase with two self-hosting targets and one hosted edition. The core never knows which it is running on. Hosted is free, with Pro at $10, €10 or £9 a month if you want it. | | Deploy to Cloudflare | Your own server | Hosted by us | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | Runs on | Cloudflare Workers on your account: D1, Queues, cron | The Docker image (amd64, arm64), or Node 22.16+ on any machine; one SQLite file | Our own servers, Node runtime, one SQLite file per business | | Set-up | The Deploy button provisions everything and asks for three values | [One command](/docs/run-it-yourself/) on Ubuntu or Debian, with HTTPS; or the Docker image, on a server or a [platform](/docs/platforms/) | From your website’s check result: confirm with a code, then add one DNS record | | Data | Your D1 database | Your SQLite file and blob directory | One database file per business on our servers | | Email out | Cloudflare Email Sending on Workers Paid, from `MAIL_FROM` on a domain onboarded there; or Resend | Cloudflare Email Sending or Resend; with neither, printed to the console | Included, from an address of ours that carries your business’s name | | Email in | Email Routing (needs a Cloudflare zone) or a provider webhook | Forwarding, a subdomain MX, or a provider webhook | Not yet: requests arrive through the inbox’s doors and its page | | Connectors | The same MIT connector SDK; you bring your own credentials (a calendar’s app password, a Google OAuth client, a Stripe restricted key, a Mollie API key…) | Same | The same connectors | | Backups | Yours (D1 time travel) | Yours: the installer backs up every night; Litestream optional ([Backups](/docs/backups/)) | Done by us: every hour, encrypted | | Network | Join any network, or none, from settings; the setup suggests network.surfingdog.ai | Same | On network.surfingdog.ai from the day it goes live; switch it off, or add others, from settings | | Domain | Your Worker route or custom domain | Yours | A hostname on your own domain, such as `inbox.yourbusiness.com`, by one DNS record | | Cost | Cloudflare’s plan; the free plan starts, sending email needs Workers Paid | Your machine | Free; Pro, if you want it: $10, €10 or £9 a month, taxes included | | Licence | MIT | MIT | Same software, same licence | Pro adds the three AI spam filters ([spam](/docs/spam/)). The $10 supports the open-source inbox, the open network, and keeping access to AI agents from being locked in a connector store. Two things are worth stating plainly. First, the open-source edition is never crippled: every connector, every door and every format in the hosted edition is in the repository. What hosting takes off a small business is the running of it: updates, email, backups and, later, connections to other services ([ADR-014](https://github.com/surfingdogai/inbox/blob/main/docs/adr/014-connectors-strategy.md)). Second, a hosted inbox’s data is stored on our own servers and its encrypted backups in object storage, both in the European Union. Requests reach them through an edge network that ends the encrypted connection and opens a new one to our servers, so that network handles message content in transit; the hosted terms say who processes what, and where. The core does not know it is hosted, so the hosted layer can change later without touching it ([ADR-003](https://github.com/surfingdogai/inbox/blob/main/docs/adr/003-hosted-tenancy.md)). Where a platform already speaks to agents, the inbox redirects rather than competes. A Shopify store already serves agents a storefront MCP and a UCP profile for catalogue, cart and checkout; the inbox covers what the platform lacks, which is quotes, bookings, questions, disputes, one inbox across channels, and receipts driven by the platform’s order webhooks.
# Setting a business up for agents: guidance
> Recommendations, not rules, from installing the inbox with a shop we worked with. Two ways to use the inbox, what handles each door, making doors findable, the full journey, how agents pay, and bridges to the helpdesk a business already uses.
This page is advice for the AI, or the person, installing Surfing Dog Inbox for a business. It comes from a shop we worked with for a week, including the things we got wrong. **It is guidance, not a set of rules.** Every recommendation here has alternatives, and a business may use other tools and other ways of doing things. The inbox is built to sit beside them. Where this page and the business’s own judgement differ, the business decides. Written on 9 October 2026. Each section keeps three things apart: * **Today**: what the inbox does now, in the code you install. * **Pattern**: something a business builds itself from today’s pieces: a webhook, a key, a small script. * **Coming**: planned, and not built yet. The step-by-step install is [install.md](/install.md). The exact calls for connecting software the business already uses are in [connect.md](/connect.md). ## Two ways to use the inbox [Section titled “Two ways to use the inbox”](#two-ways-to-use-the-inbox) ### A business with nothing for agents yet [Section titled “A business with nothing for agents yet”](#a-business-with-nothing-for-agents-yet) A plumber, a salon, a small shop with a contact form. Here the inbox is the front door for agents: they send messages, ask for quotes, book and order there, and the business answers from the owner app, with rules, or with its own AI. **What a score counts depends on the kind of business.** The Surfing Dog score weighs what matters for each kind: a salon is scored on bookings, a shop on orders, what follows them, and messages. Quote requests still help a shop’s customers; they don’t change its score. Each result page says what the inbox could add for that business, in points where the score counts it. They count once the inbox is live at the business’s domain with those doors open: messages are open from the start, bookings once there is a service and opening hours, orders once a product is on offer. **Today** the inbox handles all five kinds of item (message, quote request, booking, order, refund). It answers availability from the business’s services and opening hours, prices from its catalogue, and keeps every item’s history. Rules, with presets for appointments, trades and shops, can confirm, reply and flag on their own. The owner’s AI works inside limits the owner sets in code, not in a prompt ([Your limits](/docs/api/#your-limits)). ### A business that already has its tools [Section titled “A business that already has its tools”](#a-business-that-already-has-its-tools) A shop with its own checkout, a clinic on a booking system, a team on Intercom, Zendesk, Salesforce, HubSpot or Freshdesk, staff who answer on Telegram or WhatsApp, a business with its own AI agent. Here the inbox is a thin, standard door in front of all that: * it receives what agents send, in one typed shape, over REST, MCP and email; * it keeps a log: every item, every event, who caused it and through which door, and, when something becomes a promise (a booking confirmed, an order accepted or paid), a signed [receipt](/docs/receipts/); * it hands each item to where the business already works, and the business’s own systems, people or AI answer. How it hands things over **today**: signed [webhooks](/docs/webhooks/), the [owner API](/docs/api/) and its event stream, and email alerts to the owner. Native connectors cover calendars and payments, and from 0.2.0 shops, property systems, ordering systems and job tools ([Integrations](/docs/integrations/)), and the [product feed](/docs/feeds/) import reads a shop’s feed. None is a helpdesk, so handing items to one is a **pattern** today: a webhook and a small bridge, described in [Messages with the tools you already have](#messages-with-the-tools-you-already-have). ### Either way [Section titled “Either way”](#either-way) The inbox is the business’s own software: open source (MIT), at its own subdomain, hosted by us or on its own Cloudflare account or server. Customers and their agents write to the business there. When the business runs it itself, Surfing Dog never touches the money and never keeps what anyone writes; when we host it, it runs on our servers, and the business can download all its data and leave any time. When a business allows it, we carry an assistant’s request to the business’s own inbox and keep only a record that it was delivered. Joining a network is optional and separate: a network lists the business and records the receipts its inbox signs, and [install.md, Step 5](/install.md) lists everything a network receives. ## Decide what handles each door [Section titled “Decide what handles each door”](#decide-what-handles-each-door) A **door** is something an agent can do: send a message, ask for a quote, book, order, pay, and the steps after (change, cancel, track, return). For each one, decide with the owner what handles it: 1. **The inbox.** 2. **The business’s own system**: its own MCP server, API, booking system or checkout, at an address the business controls. 3. **Nobody**: the business does not do this through an agent. Say so plainly, so agents do not try. What we learned: * **Do not advertise a door that cannot be fulfilled.** An agent that books a slot nobody honours, or orders from a catalogue nobody ships, costs the business more than an agent that was told “not here”. * **One door per action.** If orders go to the shop’s own checkout, the inbox should not offer orders as well. Two order doors with different stock and prices confuse agents and people alike. * **Pick defaults for the kind of business**, neither everything on nor everything off. Some starting points, to adjust with the owner: | Kind of business | Message | Quote | Book | Order | Pay | | --------------------------- | ------- | ------------------------ | ---------------------------------------------------------------- | ------------------------------ | ------------------------- | | Services, nothing yet | inbox | inbox | inbox | nobody | a payment link | | Small shop, nothing yet | inbox | inbox | nobody | inbox | a payment link | | Shop with its own checkout | inbox | inbox, for bulk or trade | nobody | its own checkout or agent door | its own checkout | | Already on a booking system | inbox | inbox, if it quotes | the booking system’s door, or the inbox with a person confirming | nobody | at the booking, or a link | | Trades | inbox | inbox | inbox, after a quote | nobody | a link with the invoice | **Today** the inbox advertises what it has the means to do: `message` always, `booking` once a service booked at a time has opening hours the owner saved, `order` once a product is active, `quote_request` with either ([Manifest](/docs/manifest/)). So a shop whose orders go to its own checkout leaves products out of the inbox, and the inbox’s manifest does not offer orders. If an agent sends an order anyway, the public API still takes it as a request that waits for a person: nothing is promised until someone accepts it. Where bookings or orders are taken somewhere else, the owner app says so: Settings → General → **What agents can do** → **Take bookings or orders on another site?** takes the link to the business’s own page there, and names the platform it is on ([Booked or ordered elsewhere](/docs/connectors/elsewhere/)). The inbox then stops offering that kind itself; the manifest names the address as `doors_elsewhere` and leaves the kind out of `item_types`, and the business’s page links there. Messages and quote requests stay with the inbox. Over the API it is `doorsElsewhere` ([Your page](/docs/your-page/)), which is also how a place to stay sends guests to its own booking engine ([A place to stay](/docs/stays/#if-you-take-bookings-elsewhere-already)). **Coming**: declaring a door off and unavailable, and sensible defaults for each kind of business. ## Make every door findable [Section titled “Make every door findable”](#make-every-door-findable) Agents and crawlers find doors by reading files at known places. Make the list easy to find and hard to misread. **Keep one list of doors, and repeat it where readers look:** * **The inbox manifest**, at `/.well-known/agent-inbox.json` on the inbox’s subdomain. It lists the inbox’s own doors (REST, OpenAPI, MCP), and where bookings or orders are taken instead (`doors_elsewhere`). * **An MCP server card** for each MCP server the business runs. `/.well-known/mcp/server-card.json` is a proposed convention; a site with several servers can also list them at `/.well-known/mcp.json` as `{"servers": [{"url": "…"}]}`. Readers differ, so publishing a card as well as a list is cheap insurance. * **`llms.txt`** on the main domain, with a line per door that says what the URL is (example below). * **schema.org markup** on the pages: the business (`Organization` or `LocalBusiness`), products with an `Offer` (price, currency, availability), opening hours, and policies (see the next section). * **A product feed** (CSV or Google Merchant XML), which agents can read, and so can the inbox’s own [feed import](/docs/feeds/). * Optionally, **a DNS record** on the main domain that points at the inbox: `_agent-inbox.example.com. TXT "v=sdi1; manifest=https://inbox.example.com/.well-known/agent-inbox.json"`. Our crawler does not look this record up; other readers may, so it does no harm, but list the inbox in a file as well. An `llms.txt` section a reader can act on, one door per line, each saying what it is:
```markdown
## For AI agents
- MCP server (streamable HTTP): https://agent.example.com/mcp
- Inbox for messages, quotes and returns: https://inbox.example.com/.well-known/agent-inbox.json
- Product feed (Google Merchant XML): https://example.com/feed.xml
- Basket hand-off (an agent fills the basket, a person pays): https://example.com/basket?add=:
- Order lookup API: POST https://example.com/api/orders/lookup
- Returns policy: https://example.com/returns
```
Give the inbox’s line the manifest’s own address, the one that ends in `/.well-known/agent-inbox.json`: that is how our crawler tells an inbox in `llms.txt` from the other doors, and it reads the manifest from there. **Keep every door on the business’s own domain or a subdomain**, such as `inbox.example.com` or `agent.example.com`. A door on an unrelated host is easy to fake, so careful readers set it aside unless the business’s own site leads to it; a network that follows [the network protocol](https://github.com/surfingdogai/inbox/blob/main/docs/protocol/network.md) (§4.8) also wants the door’s own document to name the business’s domain. A booking platform’s page for the business counts once the business’s site links to it and the platform’s document names the business. **What we saw with the shop:** * **A discovery file we could not read.** Its `/.well-known/mcp.json` listed its servers as a list, and our crawler expected a single server card. Ours reads lists now; other readers may not, so publish a card as well. * **Doors listed in `llms.txt` alone.** Its `llms.txt` named an MCP server on a subdomain, a product feed, a stock API, a basket hand-off link and an order lookup. Our crawler saw that the file was there and read none of the doors in it. It reads such lines now, but a door that also sits in a machine-readable file (a server card, the manifest, an OpenAPI document) is found by more readers. * **Images on another platform’s CDN.** The shop served product images from a large commerce platform’s file CDN. A crawler, ours, took that as a sign the shop ran on that platform and went looking for that platform’s agent checkout, which was not there. We fixed the crawler; the lesson for a business is to declare its doors outright, so nobody has to guess them from side signals. Then look from outside: [surfingdog.ai/check](https://surfingdog.ai/check) reads a site the way our crawler does and shows, for each thing an agent can do there, the door it found and when. After a change, open the site’s result and press **Check again** (a fresh read can be asked for a few minutes after the last one), or have an AI call `check_business` with `fresh` set to true. Any checker will do; the point is to see what a stranger’s agent sees. ## Let agents finish the job [Section titled “Let agents finish the job”](#let-agents-finish-the-job) A booking or an order is the start of the job, not the end of it. * If agents can **order**, let them also change the order, cancel it, track it, get a receipt and start a return. * If they can **book**, let them see availability before they book, change the time and cancel. * If they can **sign up** for an account, a membership or a class, let them change it or end it. **Today**, for the items it holds, the inbox’s public API covers most of this: `check_availability`; changes to a confirmed booking or an accepted order (`make_offer`, `suggest_time`); `cancel_item`; the status and the conversation (`get_item_status`); signed receipts the agent can counter-sign; and `withdraw_from_contract` and `request_return` for orders and paid bookings ([API](/docs/api/)). Sign-ups are not an item type in the inbox. **Pattern**, when the order lives in the business’s own system: offer an order lookup there (by order number and email address) and a way to start a return, and list both with the other doors. The inbox cannot yet record an order that happened in another system, so its receipts cover the bookings and orders it holds itself. **Publish policies in words and in markup**, because agents read both: returns as schema.org `MerchantReturnPolicy` (how many days, who pays postage, how the refund is made), delivery as `OfferShippingDetails`, and the cancellation terms in plain words next to the booking. **Today** the inbox publishes its own default return policy at `GET /v1/business` as a `MerchantReturnPolicy`: where the business’s country is in the EU, the EEA, Gibraltar or the UK, or none is set, from the owner’s Returns settings until they write its terms; elsewhere a policy the owner wrote, and none until they write one. The Surfing Dog agentic score rewards the full journey: an order counts for more when changing, cancelling, tracking and a receipt follow it ([the published rules](https://surfingdog.ai/v1/score-rules)). The score is a reading, not the aim. The aim is that an agent can finish what its person asked for. ## Payments, as of October 2026 [Section titled “Payments, as of October 2026”](#payments-as-of-october-2026) This changes month by month. Check the sources at the end of this section before choosing. **Why an agent should not type a card number.** In many places, the EU (under PSD2), the UK and India among them, a card payment the customer starts needs strong authentication: a second step, such as a code, a passkey or the banking app. An agent filling in a card number at a checkout stalls at that step, or needs the person there anyway, and it holds a card number it should never see. What works is a credential made for agents: a token or a mandate the person approves once, usually with a passkey or their banking app, limited to a merchant, an amount or a period, which the agent presents and the payment provider checks. Whichever you choose, declare it where agents read it: a UCP profile lists it under its payment handlers, and a profile whose payment handlers are empty tells agents a person still pays. In the inbox, tick the ways customers pay you in Settings → Payments: they go into your manifest (`profile.payments`) and onto your inbox’s own page, and the directory shows them as declared by you. An agent’s own way to pay (x402, AP2 and the rest) is not ticked there: it is declared by the door that runs it. Your own structured data can say it too (schema.org `paymentAccepted`). Options, a line each: * **Stripe Agentic Commerce Suite, with Shared Payment Tokens**: a scoped token an agent hands to the merchant without seeing the card. It implements the Agentic Commerce Protocol’s (ACP) delegated payment and carries Mastercard Agent Pay and Visa Intelligent Commerce tokens. The short path for a business already on Stripe. * **Mollie**: has announced support for ACP’s delegated payment API, for European merchants. Check that it is available on your account before you plan on it. * **Adyen Agentic**: catalogue, basket and payment APIs for agent commerce, suited to larger merchants already on Adyen. Check which countries and plans it covers. * **Mastercard Agent Pay and Visa Intelligent Commerce**: the card networks’ own agent tokens, reached through the business’s payment provider rather than directly, and opening region by region. In Europe, for example, Mastercard says every issuer is enabled for Agent Pay at network level, and Visa’s Agentic Ready programme began there, the UK included, in March 2026, starting with banks. * **Pay by bank, with a mandate**: an account-to-account payment the payer can limit or revoke at any time, such as Pix Automático in Brazil, UPI AutoPay in India, PayTo in Australia, SEPA Direct Debit in Europe, and Direct Debit and Variable Recurring Payments in the UK. Whether a scheme lets an agent use a mandate differs from one to the next; in the UK, GoCardless has completed an agent-led pay-by-bank donation inside the FCA’s AI Live Testing programme. * **x402 and stablecoins**: payments over HTTP 402, mostly in stablecoins. Niche for a consumer business today. * **Stripe Pay** (in preview since 27 October 2026, for eligible businesses in the US and the UK): a transfer between Stripe accounts to a business’s Stripe profile handle, in USD, GBP or USDC, sent from the Stripe Dashboard or by an agent with Stripe’s CLI. The payer is a business too, with a funded Stripe financial account. In the inbox: Settings → Payments → Stripe Pay; the owner records each payment on its order, since nothing tells the inbox of the transfer. **A hand-off link is a fine start.** The agent fills the basket or the order, and a person pays on the business’s own checkout; no card details go near the agent. **Today** the inbox supports this: the business’s own system asks for payment with a link (`request_payment` with a `paymentUrl`) and records it back with `record_payment`, both with an integration key the owner gave `money:write`. Readers such as our score count a hand-off as “partly”: a person still pays, and the agent did the rest. **Wherever the business is.** The same provider products and card-network programmes reach different regions at different times, and the rules on authentication differ by country. Everywhere, a scoped token or a mandate is safer than an agent holding a card number. Sources: [Stripe Pay in the Stripe CLI](https://docs.stripe.com/cli/pay) · [Stripe’s agentic commerce updates](https://paymentexpert.com/2026/05/01/stripes-agentic-commerce-updates/) (Payment Expert) · [Mollie on agentic commerce in Europe](https://www.mollie.com/news/agentic-commerce-mollie-europe) · [Mastercard: Europe is building the foundations for trusted agentic commerce](https://newsroom.mastercard.com/news/europe/en/perspectives/en/2026/europe-is-building-the-foundations-for-trusted-agentic-commerce/) · [Visa Agentic Ready launches in Europe](https://www.fintechfutures.com/ai-in-fintech/visa-agentic-ready-launches-in-europe) (FinTech Futures) · [GoCardless and Trussell: an agentic payment by bank](https://gocardless.com/blog/uk-first-agentic-payment-trussell) · [Adyen’s agentic commerce documentation](https://docs.adyen.com/online-payments/agentic-commerce). ## Messages with the tools you already have [Section titled “Messages with the tools you already have”](#messages-with-the-tools-you-already-have) Many businesses already answer customers in a helpdesk, a CRM or a chat app, and some have their own AI that answers. The inbox does not replace any of that. The shape is the same whatever the tool: 1. A customer’s agent sends a message, a quote request, a booking or an order to the inbox, over REST, MCP or email. 2. The inbox keeps it as an item with its history, and gives the agent a way to read its status. Promises (a booking confirmed, an order accepted or paid) get signed receipts. 3. A webhook tells the business’s bridge (`message.create`, `order.create`, and so on). The bridge reads the item with its own integration key (`GET /v1/owner/items/{id}`) and opens a ticket or a conversation in the business’s tool, keeping the inbox item’s id on it; most tools have an external id or a custom field for this. 4. A person, or the business’s own AI, answers in that tool. 5. The tool’s own webhook or automation calls the bridge, which posts the answer back to the inbox item: `POST /v1/owner/items/{id}/replies`, or a move such as `confirm`, `propose` or `request_payment` with `POST /v1/owner/items/{id}/transitions`. 6. The agent reads the answer in the item’s conversation (`GET /v1/items/{id}`), and the customer gets the business’s email if they gave an address. When the customer writes again, a `.message` event fires and the bridge adds it to the same ticket. The bridge is the business’s own: a small script on any server, a serverless function, or a Zapier, Make or n8n flow. Native connectors for these tools do not exist today. The notes below are patterns, built on each tool’s public API: * **Intercom**: the bridge opens a conversation for the customer through Intercom’s API, with the inbox item’s id in a conversation attribute. Intercom’s webhook for an admin reply (`conversation.admin.replied`) calls the bridge, which posts the reply to the item. * **Zendesk**: the bridge creates a ticket with the Tickets API, with the item’s id as the ticket’s `external_id`. A trigger on a public comment by an agent calls a Zendesk webhook pointed at the bridge, which posts the comment as the reply. * **Salesforce Service Cloud**: the bridge creates a Case through the REST API, with the item’s id in a custom field. A record-triggered Flow on a new public reply calls the bridge, by an HTTP callout or a Platform Event the bridge listens for. * **HubSpot**: the bridge creates a ticket with the CRM tickets API, or uses a custom channel in HubSpot’s conversations inbox. A workflow’s webhook action, or the app’s webhook subscription, tells the bridge when someone replies. * **Freshdesk**: the bridge creates a ticket with the Tickets API. An automation rule on ticket updates, with a “Trigger webhook” action, calls the bridge when an agent replies. * **Telegram**, for staff who answer on their phones: the bridge’s bot posts each new item to a staff chat (the Bot API’s `sendMessage`), with the item’s reference. When someone replies to that message, Telegram sends the update to the bridge’s webhook, and the bridge maps the replied-to message back to the item and posts the reply. * **WhatsApp Business**: the same pattern for staff alerts, through the WhatsApp Business Platform (the Cloud API) or a provider such as Twilio. Messages the business starts outside WhatsApp’s 24-hour window need approved templates. WhatsApp is a channel for people, not a door for agents: agents write to the inbox. * **Email**: **today** the inbox emails the owner about new requests at the address in `notifications.ownerEmail`. Point it at a shared mailbox, or at a helpdesk’s address, and each request lands there with no code at all. For an answer to reach the customer’s agent, it has to go back through the inbox: the owner app, the owner MCP, or `reply` over the API. * **The business’s own AI**: two ways **today**. Connect it to the owner MCP at `https://inbox.example.com/mcp/owner`, where it signs in with OAuth, lists what needs an answer, replies and moves items, inside the limits the owner set ([Connect your AI](/docs/connect-your-ai/)). Or let it react to webhooks and call the owner API with its own integration key. Either way its replies reach the customer marked as sent automatically, and anything about money outside the owner’s limits waits as a draft for a person. Get these right, whichever tool it is: * **One integration key per bridge**, made by the owner in Settings → Keys with the narrowest preset that works (`automation` for most bridges). * **Skip your own echo.** An event whose `data.actor.id` is the bridge’s own key is the bridge’s own write coming back; acting on it again is how two systems loop. * **Use the event’s id as the idempotency key** on every write back, so a retry does the work once. * **Thin events**, the default: the bridge reads the item with its key, and no customer data travels to the webhook address. * **`written_by: "person"`** on a reply a person typed in the helpdesk, so the customer does not see the line saying it was sent automatically. * **The helpdesk’s own tokens stay in the bridge**, never in the inbox’s settings, notes or replies. * **Test both directions with sandbox items** (the `x-sandbox: 1` header) before any real customer. ## A checklist for the installing AI [Section titled “A checklist for the installing AI”](#a-checklist-for-the-installing-ai) **Ask the owner**, and do not guess any of these: * Which tools do you use today for messages, bookings, orders, payments and support? The product names and the plans. * Does anything already answer AI agents for you: your own MCP server or API, a platform’s agent checkout, a booking platform’s agent door? * For each action (message, quote, book, order, pay, change, cancel, track, return): should the inbox handle it, your own system, or nobody? * How do you take payment, and does your provider offer agent payments yet? If not, is a payment link to your own checkout fine for now? * Where are your returns, delivery and cancellation policies written, and may we add markup to those pages? * Who answers: a person, rules, or your own AI? What may automation agree to on its own, for times and for money? * Do you want to join a network? It is optional, and separate from the inbox. **Then:** * Install with [install.md](/install.md), and set up the doors the business will fulfil, and no others. * Connect the tools it already uses, with [connect.md](/connect.md) or a bridge as above. * List every door on the business’s own domain: the manifest, an MCP server card, `llms.txt` lines, schema.org markup, a product feed. * Test each door end to end with a sandbox item, in each direction a bridge runs. * Look from outside with a checker. * Tell the owner, in a few lines, which doors are live and where, which are handled elsewhere, which are off, and what is not built yet. None of this is required. If the business has a better way, use it, and tell us what worked on the [requests board](https://surfingdog.ai/requests).
# Spam filter
> How the inbox keeps sales pitches, scams and junk off your desk without losing a customer, what it reads, what leaves your server and when, connecting your own AI model, and what a sender sees.
The inbox screens every request from someone new as it arrives, on every door: email, your own website, and AI agents. What is not from a customer waits under **Spam** or **Held** instead of on your desk, kept for as long as you choose, one tap back. You aren’t alerted, except once on your phone for a booking held for the next two days. Settings → **Spam**. The design is ADR-041 in the repository’s `docs/adr`. ## What it never does [Section titled “What it never does”](#what-it-never-does) * It never refuses, bounces or deletes a request for what it says. The worst a request meets is being put aside or held, kept and explained, and its sender sees the same pages and answers as without a filter; a request held for a certain reason gets no acknowledgement (see [What the sender sees](#what-the-sender-sees)). A sender starting far more new requests in a day than a person would is asked to try again later. * It never puts aside or holds someone you know: a customer you have answered, confirmed or let in, anyone on **Let through**, a reply in a conversation, and everything your business makes itself. A check that points at a third party (a change of bank details, a look-alike or disguised link) can still label them, and an address on Let through typed where nothing vouches for it is read like a stranger’s, labelled at most. * It never uses language, script, country, name, spelling, length, email provider, time of day or “written by an AI” as a reason. Checks on characters look for disguise and nothing else, such as a Cyrillic letter inside a Latin word. * Whatever a filter does or fails to do, a request goes through: a check that times out, fails or runs out of room lets it in on what the free protection found. ## Four outcomes [Section titled “Four outcomes”](#four-outcomes) | | You | Your rules, webhooks, phone | The sender | | ------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- | | **Delivered** | alerted as usual | as usual | as usual | | **Delivered with a label** (“May be a sales pitch”) | alerted; on Needs you with a small chip | rules may flag it and do nothing more: nothing is confirmed, quoted, answered or asked for | as usual | | **Held** (bookings, orders, quote requests) | listed under Held; a morning email if you want one | nothing runs; no time, stock or pay link is taken | as usual (“we have your request”), or nothing when a certain signal held it | | **Put aside** (messages; a quote request for a certain reason, never otherwise) | listed under Spam with why | nothing runs | a message reads as open, one nobody answered yet; a quote request as received | A booking or an order is never put aside, at most held. A held booking holds no time: another customer can book and be confirmed for the same slot. A held kitchen order takes no place in its slot or its dishes’ daily count; let in, it takes them then, as a new order would, and where they are full by then it waits for a person instead of being accepted at once. A held request lapses by its own clock, silently. Requests for a stay (rooms and nights) are not screened yet: they arrive as they would without a filter. **Let it in** (Held) and **Not spam** (Spam) run what was held back, once: the rules and the webhooks. A request held for a bot’s signal got no acknowledgement, and gets none after Let it in either. **Looks fine** takes a label off. Each is one press, with **Undo** for six seconds, and teaches the filter: that sender comes straight in from then on. ## Free protection, always on [Section titled “Free protection, always on”](#free-protection-always-on) On every door, on every inbox, with no AI: * **Form bots**: your form’s hidden field filled in (each form names it differently), a form your page never drew, one filled in faster than anyone types. * **Mail a server marked as spam** on its way. From someone you know it labels, nothing more. An address makes someone known where something vouches for it (a mail server, a pass). Typed at a form, given to an assistant or on mail no server vouches for, it is read as a stranger’s is, and labelled at most. * **Posing as you**: your business’s name from an address that isn’t yours, an address made to look like yours, answers that would go elsewhere (Reply-To), a display name showing another address. * **Disguised links**: a link showing one address and going to another; a link that hides where it goes (a bare network address, a name written before the site’s). * **Waves**: the same message, nearly the same message, or the same link from many senders this week. A short honest question repeats freely, and bookings worded the same way by a platform never make a wave. * **Floods**: more new conversations in a day than a person starts. A sender is known by their agent’s own key, their pass, their mailbox and their mail domain, never by their network address, and never by an AI platform’s key, which signs for all of that platform’s users. An address nothing vouched for (typed at a form, given to an assistant, on mail no server vouched for) is counted apart from the same address’s signed mail and never gets anyone refused, and what a bot sent counts against nobody. A request that names nobody at all still counts in a wave. Its point-based outcomes started with a learning week of their own, from the day your inbox was updated. ## Three filters [Section titled “Three filters”](#three-filters) | Filter | Catches | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Sales pitches** | SEO, links and guest posts, sponsored articles, websites, apps, ads, leads, reviews for sale, social growth, loans, outsourcing | | **Scams** | False invoices, changed bank details, account, domain, listing, legal or copyright notices, review threats, overpayment stories, prizes and jobs, sign-in links. A change of bank details from someone you know is labelled “Check before paying”, never put aside | | **Junk** | Random text, tests, link lists, adult, gambling, crypto and pharmacy promotion | Each filter looks for known phrases on its own, in many languages, and reads with an AI model where one is set up (one reading judges all three). For each, choose **Put aside** or **Just label**. On a hosted inbox the three filters come with **Pro**, and start on. On **Free**, basic protection keeps working, with no filters and no AI. On your own server you switch on the ones you want. **Learning week.** For seven days from the day a filter is switched on (on Pro, the day Pro starts), it labels what it would have put aside and puts nothing aside (“Would have been put aside”). **Start now** ends it. Switching off and on within thirty days does not start it again. A filter you switch off stays off; one switched off because Pro ended comes back as it was when Pro does. ## How it decides [Section titled “How it decides”](#how-it-decides) Certain signals decide alone: a hidden field filled in, a form never drawn, a server’s mark, a sender you put aside, a wave, a message you marked as spam sent again, or one like it from the same sender. One like it from someone else adds points, nothing more, since assistants write many people’s requests the same way. The rest add points: from 3 it labels, from 6 it puts aside or holds. Then each of these can lower the outcome, never raise it: * someone you know is labelled at most; * words that name your own services or products keep the outcome at a label; * a network pass presented keeps what the words alone would do at a label; * a filter on Just label, or in its learning week, labels; * the AI check may put a message aside on its own when it is sure, quotes the message word for word, and found nothing a customer would ask; never otherwise. Under **Why it’s here**, an item says the strongest reason in a sentence, the others a tap away, the passages found (“It says: …”), and who decided: the spam filter, the AI check, a mail server, or you. ## The AI check: what is sent, and what never [Section titled “The AI check: what is sent, and what never”](#the-ai-check-what-is-sent-and-what-never) It runs on a stranger’s opening message, with a filter on, when nothing certain decided, the message has words enough, and there is room under the caps. * **Sent:** the message’s text with every email address, phone number, IBAN, account and card number, payment address and link taken out (a link is cut to its domain), in any script’s digits, the subject and the sender’s name masked the same way, the sender’s domain (never their address), the domains its links go to, how many files it has, and your business’s name, description, languages and the names of your services and products. * **Never sent:** addresses, phone numbers, anything about other customers, your settings, attachments. * **The free protection** sends nothing: what it reads stays on your server. With an AI check, never without one, a new sender’s mail domain is looked up in DNS (at a public DNS-over-HTTPS resolver) to see whether it takes mail. * The text sits between two random boundary lines, with a fresh value the answer must return. The answer is held to a schema of fixed choices; no sentence a model writes reaches you, a customer or your rules. Instructions inside a message change nothing, and a message that talks to an assistant or a filter is never delivered without a label. * Eight seconds an attempt, one retry after two, twenty seconds in all; any failure lets the request through. * The same text from another sender this week reuses the answer instead of asking again. ## Hosted: Free and Pro [Section titled “Hosted: Free and Pro”](#hosted-free-and-pro) | | Free | Pro: $10, €10 or £9 a month, taxes included | | ----------------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------ | | Basic protection, Held and Spam, the lists, the emails | yes | yes | | The three filters | no | on from the start, each with its learning week | | The AI check | no | Gemini from Google (Gemini 3.1 Flash-Lite), which we pay for | | [Known spam](#known-spam-shared-between-hosted-inboxes) from other businesses | labelled; your Not spam counts for them | labelled; your Spam and Not spam count for them | The $10 supports the open-source inbox, the open network, and keeping access to AI agents from being locked in a connector store. There is no price for each filter. **A Pro we give for free** says so in Settings → Spam, with until when. Its filters are on and look for known phrases, and its AI check stays off until the owner turns it on there: turning it on says what it sends. The owner switches it, signed in, and nobody else. Settings → Spam names who reads. We use Google’s Gemini API from a Google Cloud project with billing on, so Google’s [terms for paid services](https://ai.google.dev/gemini-api/terms) apply: Google does not use what it is sent to improve its products, and keeps it for [55 days](https://ai.google.dev/gemini-api/docs/usage-policies), solely to detect and prevent misuse of its service and for disclosures the law requires; what its checks flag may be read by authorised Google staff. It may be stored for a while or cached in any country where Google has facilities. That is Google’s standard retention for the paid Gemini API, not a zero-retention arrangement. Each check is sent with request logging off, so our own Google project keeps no log of it. Where a Pro inbox has no AI check (a Pro we give for free, until its owner turns it on, or while the check rests), Settings says so, and the filters look for known phrases. ## Known spam, shared between hosted inboxes [Section titled “Known spam, shared between hosted inboxes”](#known-spam-shared-between-hosted-inboxes) Hosted inboxes share fingerprints of known spam with each other, never messages. A fingerprint is a set of hash values made from a message’s text with its links, email addresses, phone numbers, bank and card numbers and every digit taken out, and the sender’s company mail domain where a mail server vouched for the address (never a provider many people share). The host keys each value with a secret of its own before keeping or comparing one: a fingerprint can’t be turned back into the message, and nobody without that secret can match one. * **Compared.** Each new message from someone you haven’t dealt with is compared, by its fingerprint, as it arrives. Senders you know, and requests a network’s pass speaks for, are not looked up. * **Known spam** is a message like one that three or more other hosted inboxes marked as Spam within a fortnight, or put aside on their own and were sure of: a bot’s form, a server’s mark, the same words from many senders, or the AI check sure of it with a passage it quotes. Inboxes count, not addresses: each counts once, however often its owner changes their email address, and none before its account is a week old. * **A match labels** it (“Seen as spam at other businesses”), on Free and on Pro, with your filters on or off, and on its own does nothing more. It helps put a message aside when this inbox’s own checks or its AI check found enough to label it without the match, adding its points to theirs, and never beside less; your filters’ learning week, Just label, and the AI check finding a customer still lower it. It never holds a booking or an order, never deletes, never blocks a sender, and never counts in receipts, standing or ranking. * **A sender’s domain** known for spam counts for mail a mail server vouched came from it, never for an address typed at a form or given by an assistant, and adds a few points, labelling nothing alone. * **Not spam**, Let it in or Looks fine on a match counts against it for everyone, from any hosted inbox, Free included. What a person marked, or what an inbox was sure of on its own, is reported; never what a match labelled or helped put aside, so the list never feeds itself. * **Free and Pro.** Every hosted inbox reads the list: it costs no AI. Pro inboxes live at the business’s own domain report spam to it, and no others, so marks can’t be faked by the dozen. A Free inbox’s Spam marks stay its own; its Not spam on a match counts, since that can never do more than let a message through. * **Forgotten** thirty days after it was reported. Erasing a customer takes back what your inbox said of their messages. * **On your own server** nothing is shared, and nothing others shared is read. A shared list may be offered for your own server later. Settings → Spam says which of these your inbox does. Your privacy page says, in your name, that each new message is compared by its fingerprint, whether your inbox shares the fingerprints of what you mark as spam, and that a fingerprint can’t be turned back into the message. ## Connecting a model [Section titled “Connecting a model”](#connecting-a-model) On a **hosted** inbox you connect nothing: on Pro the host’s model reads, and you cannot bring your own key there yet. On **your own server**, either: * **Settings → Integrations → Your AI model**, the owner’s own to set, signed in: choose Anthropic, OpenAI or Google Gemini, paste a key from your account (it is kept sealed under `INBOX_SECRET_KEY` and never shown again, nor downloaded), choose a model from the provider’s own list (smaller models cost less and are enough here), and press **Test**: two samples go through, a sales pitch that should be spotted and a customer asking a price in Japanese who should come through. On Google’s free tier, what you send may be used to improve its products: use a paid key. * or the server’s environment, which then shows as “Set by your server”: | Variable | | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `SPAM_MODEL_API` | `messages` (the Messages API) or `chat` (any chat-completions API) | | `SPAM_MODEL_URL` | for `chat`: the API’s base, up to and without `/chat/completions`; a model on your own machine, such as `http://127.0.0.1:11434/v1` | | `SPAM_MODEL_KEY` | the key, where the API needs one | | `SPAM_MODEL` | the model’s id | | `SPAM_MODEL_NAME` | how Settings names it (optional) | Without a model, the filters look for known phrases, and the free protection works the same. **Most checks a month** (your own server): 500, 2,000 (the default), 10,000 or no limit. Your provider charges you for each check; Settings shows this month’s checks and the model’s time. ## Caps, on a hosted inbox [Section titled “Caps, on a hosted inbox”](#caps-on-a-hosted-inbox) On Pro, each inbox has a day’s and a month’s room for AI checks (150 and 1,500 unless your host says otherwise), counted wherever your inbox runs, and the host has a daily ceiling for all the inboxes it runs together. When room runs out, Settings says until when the AI check rests, and the filters look for known phrases until then. ## The lists [Section titled “The lists”](#the-lists) * **Let through**: people you marked Not spam, let in or said looks fine. A company’s domain can be added from the toast (“Let everyone at {domain} through”); a provider many people share never can. * **Always put aside**: senders you chose to put aside after pressing Spam. It is never automatic, and a domain is never blocked by inference. Settings → Spam lists both; each row has **Remove**. ## Your own website’s forms [Section titled “Your own website’s forms”](#your-own-websites-forms) * **The inbox’s own form**, on any site: put it in a frame. It carries no script and no puzzle, and no site but your own (the address in your profile or Settings → Be found) may frame it.
```html
```
`/p/quote?embed=1` and `/p/book?embed=1` work the same way. Each form is signed as it is drawn, so one sent without opening it is known. * **Your site’s own forms**: make a key with **Website forms** (Settings → Keys). It may do one thing, and it is never given to an AI app: `POST /v1/forms/submit` with what the visitor sent.
```json
{
"form": "contact",
"page": "https://yourdomain.com/contact",
"fields": { "name": "Mei Lin", "email": "mei@example.org", "message": "Do you deliver on Sundays?" },
"visitor": { "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 …" },
"signals": { "trap_filled": false, "elapsed_ms": 48000 }
}
```
It arrives as a message, screened as one sent through the inbox’s own form. Each visitor is limited by their own address, the same submission sent twice is one message, and the answer is the same whatever the filter decides. The inbox’s WordPress plugin points AI agents at your inbox. It does not pass your form plugin’s messages on yet: until it does, a developer can send them to the forms door as above. ## What the sender sees [Section titled “What the sender sees”](#what-the-sender-sees) The same page after sending, the same answers to their assistant and the same status when they look again as without a filter. A held request reads as received, byte for byte. What differs is below: a request held for a certain reason gets no acknowledgement. * **Confirm it’s me.** Every new customer’s acknowledgement (bookings, orders, quote requests) carries one more line and a button: “Please confirm it’s you, so we know your request reached the right person.” Pressing it proves their address and, if their request was held for an uncertain reason, lets it in. Their later requests are screened as before, with more room for new ones in a day; a sender becomes known when you press Let it in or Not spam. A request held for a certain reason (a bot’s form, a server’s mark, a wave) gets no acknowledgement at all, so a forged address is never written to. * **An assistant’s answer** says where that email went (`identity.confirm.sent_to`, masked) — see [Getting your person’s request seen](/for-agents.md). * **Too many new requests** from one agent: `429` with `Retry-After`, and a line in the sender’s language, with no reason given. * Your **privacy page** gains a short “Keeping out spam” section while a model may read, or while your hosted inbox reads the shared list of known spam, and loses it when neither is so. ## Your emails and your phone [Section titled “Your emails and your phone”](#your-emails-and-your-phone) * **A morning email** at nine, your time, when something new was held: who, what they asked for, why, and a link to Held. Switch it off in Settings → Spam. * **A summary on Mondays** at eight when something was put aside: how many, how many are held, and the three that looked most like customers. * **Your phone** hears nothing of what is held or put aside, except once, with no name, of a booking request held for a time in the next 48 hours. It lists Held and Spam, and lets you press Let it in, Not spam or Spam. Filters are switched on the web, signed in. ## Your own AI [Section titled “Your own AI”](#your-own-ai) The owner MCP’s `spam_status` reads the filters, this month’s counts, who reads, the AI check’s room and the lists. `list_items` with `screen: "held"` lists what waits; put-aside requests are listed when asked for, never by default. Switching a filter, letting something in and the lists are a person’s, signed in: never your AI’s, a rule’s or a key’s. ## Removing data [Section titled “Removing data”](#removing-data) Erasing a customer removes the filter’s verdicts on their requests, their prints, the AI check’s answers about their words, their rows on the lists and their handles’ history, and takes back what your hosted inbox told the shared list of known spam about their messages. A download of your inbox keeps the verdicts, the lists, when each filter was on and the day counts; never the prints, the handles, the cached answers, or your model’s key. **Keep what’s put aside**: **Keep them** (the default), 90 days or 30 days. Nothing is deleted on its own, a bot’s form messages included; nothing you brought back is ever removed.
# A place to stay
> Hotels, guesthouses, B&Bs, hostels and holiday rentals. Rooms, rates, policies, taxes and fees set once; requests answered with your own total; a stay confirmed at once only where a room cannot be sold twice.
Your Inbox takes stays the way a place to stay sells them: nights between two dates, rooms for so many guests, a total with every tax and fee in it, and your own cancellation policy. Guests’ assistants ask, quote and book through it; you answer from the app, or let your rules draft the answer for you. Settings → **Property**, or **A place to stay** in the setup wizard, which can also bring your rooms from the property system you already use. ## What you set up [Section titled “What you set up”](#what-you-set-up) * **The place** — what kind it is, the town, the currency, check-in and check-out times, how soon you answer, and how long a quote holds. How long a guest has to pay what is due, and how long a guest’s assistant may hold a room, show only where they apply. * **Rooms** — each kind of room once: how many you have, how many sleep in one, the beds, what is in it. The setup wizard asks a price a night with each, and puts it on a standard plan (room only, free to cancel until 7 days before, nothing now). * **Plans** — what a stay includes (room only, breakfast…), what cancelling costs (free until a few days before, or non-refundable), and when it is paid: nothing now, the first night, a share, or everything; the rest at the property or some days before arrival. * **Prices** — a price a night per room and plan, for so many guests, and seasons or weekends that differ. * **Taxes and fees** — a tourist or city tax, a cleaning fee: per stay, per night, per guest, or a share of the room price, collected with the booking or at the property. Every one is in the total a guest sees. * **House rules and arrival** — quiet hours, children and pets, how to find you, and the door code, key box or Wi-Fi, which are sealed when saved and sent only to confirmed guests, two days before. Until you have prices, you type a price for each request in its quote; your taxes and fees are added to it. ## How a stay is confirmed [Section titled “How a stay is confirmed”](#how-a-stay-is-confirmed) You choose, and the inbox says it back in one sentence, the same sentence guests’ assistants are given. * **You answer each request** (where everyone starts). A guest asks for dates; you send a quote with your total, your policy dated for those nights, what is due when, and until when it holds. If they accept in time, the room is theirs. * **Rooms you keep for direct guests.** Close some rooms on your other channels for some nights, then keep them here: Availability → **Nights** → **Keep rooms for direct guests**. Inside those nights a guest’s assistant books at once. It saves only once you tick that they are closed everywhere else. * **Your property system.** Once your system is connected, a stay is written there first. Nothing is confirmed at once until you switch it on, and you can only after a first stay has been written there and read back. See [Property systems](/docs/property-systems/). A calendar link from another channel (Airbnb, Booking.com, Vrbo) is a hint: the night shows as likely taken and you are asked to look. It never confirms or refuses a stay on its own. ## Money [Section titled “Money”](#money) * Deposits, balances and a card to hold a stay all go through **your own payment account** (Settings → Payments). Each has its own link, sent when it is due; a deposit’s link closes when the time to pay ends. * Paying at the property works everywhere, with nothing to connect. Until a payment account is connected, a plan asks nothing before arrival: its quotes say everything is paid at the property, and you are told why. * A payment that arrives after the time to pay ended confirms the stay only inside the rooms you keep for direct guests, where nothing can have sold them since (or, with your system connected, once your system takes the stay again). Anywhere else the inbox writes down a refund you owe, with the amount, and refunds nothing itself: you may have sold those nights elsewhere. You refund it from your own account. * A payment in another currency than the stay’s is never counted: it waits for you to look at it. ## Changes and cancellations [Section titled “Changes and cancellations”](#changes-and-cancellations) A guest asking to change dates sees the new total and what it means to pay or get back before anything is sent to you. A guest cancelling a confirmed stay sees what it costs under your policy, as of now, and confirms it first. You can write down a cancellation made by phone as of when the guest asked; only you, not your AI, can charge less than your policy says. ## Your day [Section titled “Your day”](#your-day) * **Today** (the inbox’s first screen): who arrives, with their arrival window, who leaves, who is staying, and what is still to pay at the property. * **Nights** (Availability): each room type by the next two weeks, with stays, holds, the rooms still free for direct guests, closed nights and other calendars’ hints. A place to stay takes nights, not hours: the opening hours appear only once you also offer something booked by the hour. ## What your AI may do [Section titled “What your AI may do”](#what-your-ai-may-do) Your own AI may fill in rooms, prices, taxes, fees and extras that guests are not offered yet, read the Nights grid and Today, and draft a quote from your rates. It sends a quote, or says yes to a change, only where a room cannot be sold twice (rooms you keep for direct guests, or your system confirming at once); anywhere else it is kept as a draft for you, since only you can check your other channels. How stays are confirmed, rooms kept for direct guests, policies, deposits, a card to hold a stay and your registration number are yours alone, in the app. ## Your guests’ words [Section titled “Your guests’ words”](#your-guests-words) Guests are written to in English, Spanish, French, German, Italian or Portuguese: theirs when they gave one, else the first language you chose. Money and dates are written as that language writes them where you are (Spanish in Mexico, Portuguese in Brazil). Japanese, Chinese, Arabic, Turkish, Indonesian and Thai are next; until then those guests read English. The private arrival details go only in their own email, two days before arrival by default, and only to a confirmed guest: never in a reply, whoever asks for them. ## Your page, as agents read it [Section titled “Your page, as agents read it”](#your-page-as-agents-read-it) Your inbox’s front page carries your place as schema.org reads it (the place, its rooms, and a price only where a stay is booked at once, a night with every tax and fee in it). The page itself is not indexed by search engines; the block is there for agents, and for your own site to copy. ## If you take bookings elsewhere already [Section titled “If you take bookings elsewhere already”](#if-you-take-bookings-elsewhere-already) If your booking engine is where stays should go, keep them there. For now this is a settings write over the [API](/docs/api/), with your owner key; the app’s screen for it comes later:
```json
{"doc": {"doorsElsewhere": [{
"itemType": "stay",
"url": "https://engine.example.net/your-place",
"urlTemplate": "https://engine.example.net/your-place?in={check_in}&out={check_out}&adults={adults}"
}]}}
```
Guests’ assistants are then sent to your engine with the dates they asked for filled in (`{check_in}`, `{check_out}`, `{adults}`, `{children}`, `{rooms}`). Your own AI cannot change this: it sends your guests to another address. ## UCP Lodging (off for now) [Section titled “UCP Lodging (off for now)”](#ucp-lodging-off-for-now) [UCP Lodging](https://ucp.dev/draft/specification/lodging/booking/) is a draft booking standard some AI platforms are starting to use. While it is a draft it is off, and hosted inboxes keep it off. On your own server, `INBOX_UCP_LODGING=on` turns it on (it needs `INBOX_SECRET_KEY`). It offers only rooms that confirm at once: rooms you keep for direct guests, or rooms your connected system confirms instantly. If you answer each request yourself, your rooms are not offered there, since the standard needs a firm total up front; assistants still reach you as before. * A platform asks for your rooms and dates and gets your total, every tax and fee in it, with what is paid now, later and at the property, and your cancellation policy for those nights. A tax or fee you collect yourself, and your cancellation terms, are shown to the guest before they book. * Once the guest gives a name and an email or phone, the room is held for your hold time. Booking confirms the stay, or sends the guest to your own payment page when a deposit or a card is due. No card reaches your inbox, and only you charge. The stays these sessions make are ordinary stays: they appear in your inbox, follow your rules, and are yours to cancel or change. You keep the lead guest’s name and contact and the other guests’ names, as on every stay; the ids a platform gives each guest are kept only encrypted, with the session, and go with the stay if you erase the guest.
# Trades and projects
> For plumbers, electricians, builders and every trade that quotes. Where you work, emergencies, call-out fees, photos and plans on a request, quotes in stages, site visits, and your job tool.
A request for a price from a customer’s assistant can say what the job is, how urgent, whether you should see it first, where it is, when suits, and bring the photos. You decide where you go, your call-out fee, and which requests go into the tool you run your jobs in. Settings → **Area** and **Work**. ## Where you work [Section titled “Where you work”](#where-you-work) * **Postal codes**, or their start: `04` covers every code from 04000 to 04999; in the UK, `SW1` covers SW1A 1AA, and `M1` covers M1 1AE and not M12. Each country’s codes are checked in their own shape. Most assistants send a postal code, so this is the one that is checked most often. * **A distance**, measured from your premises (as Settings → General has them on the map) or from where your phone is when you set it: **Where I am now**. Neither is ever shown to anyone. Agents are told the distance, and a site is told back to you as a band: *inside your area · within 10 km*, never a distance to your door. From your phone’s place, the distance is measured from the middle of the few kilometres around it, so nobody can find your door by asking: its edge is approximate, by up to about 4 km. A distance is checked only when an assistant sends the site’s location. * **Anywhere.** A request from outside is yours to decide, or declined, as you choose. When a site cannot be checked — no location for a distance, a postal code that is not one — the request comes to you: nothing is ever said to be inside when it is not known. ## How you take work [Section titled “How you take work”](#how-you-take-work) * **Call-out fee**, and whether accepting your quote takes it off. * **Outside opening hours**, what a visit adds: a fixed amount, or a percentage. Opening hours are in Settings → Availability. * **Free estimates**, said in your public profile. * **Emergencies online**: off, only while you are open, or at any hour. An emergency comes first in your inbox; one whose site cannot be placed comes to you to decide. One you do not take is answered with what to do instead: ask within 24 hours, or call you, on your phone number when Settings has it. * **Site visits**: choose the service a visit is booked as, or add a Site visit service in one press, and a customer’s assistant books one beside the request it is for. You confirm it. ## Photos and plans [Section titled “Photos and plans”](#photos-and-plans) A request can carry up to six files: photos (JPEG, PNG or WebP) and plans (PDF), up to 10 MB each. An iPhone’s HEIC photo is refused with *send it as a JPEG*; most phones can share a photo as JPEG. * **Where a photo was taken is taken out.** Every photo is cleaned on arrival: its location, the time, the phone, any thumbnail of what was cropped. Only which way up it is stays. A PDF is kept as it was sent, with any photos inside it as they were. * **Yours alone.** They are kept in your inbox, never published, never sent to a network. You open them from the request, through links that work for ten minutes. * **Gone with the customer.** Erasing a customer erases their files. * A hosted inbox keeps up to 100 MB of files. Files waiting for a request have their own room (60 MB, 30 MB from any one sender), so nobody can fill it with uploads they never send. ## Quotes in stages [Section titled “Quotes in stages”](#quotes-in-stages) On a request, **Quote in stages…**: an estimate or a fixed price, the total, and up to four stages that add up to it — a deposit now, a stage by a date, the rest on site. The last stage is always the rest, so a deposit and the balance take one amount. Your call-out fee from Settings → Work, taken off the price or not. Whether your prices include tax; when they do not, the tax on top, and the total with it is what the customer agrees to. The customer’s assistant shows your person the whole of it before they say yes: *This is an estimate. It is paid in stages: Deposit A$1,000.00 now; Balance A$3,400.00 on site. Prices are before tax: A$4,000.00 + GST A$400.00 = A$4,400.00.* An accepted quote in stages asks for no payment of the whole: you ask for each stage when it is due. ## Your job tool [Section titled “Your job tool”](#your-job-tool) Settings → Integrations → **Job tool**. See [Job tools](/docs/job-tools/) for what each one does. Which requests go in is yours to choose, in Settings → Work: * **Only ones I take on** (the default): a request goes in when you quote it, or when you press **Send to …** on it. * **Every request**, as it arrives. * **Never.** A test request never goes in.
# Webhooks
> Signed, retried, replayable events for every booking, order, quote and message — and a polling cursor for anyone who cannot receive them.
Your Inbox tells your systems what happened, the moment it happens. A new booking, an order that was paid, a customer’s reply: each one is an **event**, and an event is an HTTP `POST` to a URL you own, signed so you can prove it came from your Inbox and retried for a day if your server is down. There is no platform to join, no OAuth dance and no app to register. You register a URL with one `POST` — or ask your AI to — keep the secret you are shown once, and answer `2xx`. ## What an event is, and when one is sent [Section titled “What an event is, and when one is sent”](#what-an-event-is-and-when-one-is-sent) An event is one thing that happened to one item. Items are the five kinds of thing an Inbox holds — `message`, `quote_request`, `booking`, `order` and `refund` ([Concepts](/docs/concepts/)) — and every move an item makes is an event: the moment it is created, every transition of its state machine, a flag being changed, and every message a customer sends on it. Events are emitted for what the Inbox itself records, whoever caused it: a customer’s agent calling `create_booking`, you pressing **Confirm** in the app, your AI firing a transition through the owner MCP, a rule expiring a quote at midnight. An event is sent after the write has committed, so by the time you receive one, `GET /v1/owner/items/{id}` already answers with the new state. Sandbox items ([Concepts](/docs/concepts/)) produce events too — every event says so in `data.sandbox` — so you can build against a test item without touching anything real. An email that gives you a way into an account you hold — a sign-in code or link, or a password reset, from the service’s own domain — is never an event, on a webhook or in the polling stream, and a key or your AI reading items never sees what it says: only a person at the business reads it, in the app. The same holds for a notice: an email from a system (a receipt, an account’s or a newsletter’s mail) that answers no conversation, kept under Notices. Every event also says who caused it and through which door: `data.actor` is `{kind, id, name}`, where `kind` is `owner`, `owner_ai` (your AI, over OAuth), `integration` (a key you made for another system), `connector`, `rule`, `system`, `customer_agent` or `customer_human`, `id` is the user, AI app or key id, and `name` is the key’s or AI app’s name; `data.channel` is the door (`rest`, `mcp_owner`, `mcp_public`, `email`, `owner_ui`, …). A customer’s id is never sent. **A two-way sync should skip the events whose `data.actor.id` is its own key’s id**: those are its own writes coming back, and acting on them again is how two systems end up in a loop. ## The event types [Section titled “The event types”](#the-event-types) The type is `
- .`. Subscribe to the exact types you want, to a whole item type with `booking.*`, or to everything with `*`, which is the default. An endpoint that subscribes to nothing receives nothing: there is no implicit “all”. | Type | Sent when | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `booking.create` | Someone requested a booking; or a customer accepted a quote for one, and it arrives already `confirmed`. | | `booking.request_info` | You asked the customer for more details. | | `booking.provide_info` | The customer answered with the details. | | `booking.propose` | You proposed another time, or another one in place of the time you proposed before. | | `booking.accept` | The customer accepted the time you proposed. | | `booking.counter` | The customer asked for another time instead of the one you proposed; the booking is back with you, at that time. | | `booking.retract` | You withdrew the time you proposed (it said it was subject to your confirmation); the request is back with you. | | `booking.confirm` | You confirmed the booking; the slot is claimed. From `proposed`, at the time you proposed: the customer said yes to a person. | | `booking.decline` | You declined the request. | | `booking.cancel` | The customer cancelled: before it was confirmed (declining the time you proposed is one), or within your cancellation window. | | `booking.cancel_late` | The customer cancelled a confirmed booking after your cancellation window, and you record late cancellations (`booking.lateCancellation`). | | `booking.cancel_by_business` | You cancelled the booking. | | `booking.record_cancel` | You recorded a cancellation the customer asked for, by phone, email or in person: theirs, not yours. | | `booking.record_cancel_late` | The same, when they asked after your cancellation window and you record late cancellations. | | `booking.expire` | The request lapsed: nobody answered it in time (`booking.autoExpireHours`), or the customer did not answer the time you proposed by its deadline (`data.actor.kind` is `system`); or a rule expired it. | | `booking.propose_change` | A change to the confirmed booking was asked for: by you (another time, a price of yours), or by the customer (another time); `data.actor` says who. It stays as agreed until the other side accepts. | | `booking.accept_change` | The change was accepted: the booking moved to its new time, its places with it. | | `booking.decline_change` | The change was not accepted: by you, or by the customer, who keeps what was agreed. The booking stays as it is. | | `booking.retract_change` | Whoever asked for the change took it back (yours only when it said it was subject to your confirmation). The booking stays as it is. | | `booking.expire_change` | Nobody answered the change by its deadline (`data.actor.kind` is `system`). The booking stays as it is. | | `booking.record_payment` | A payment or deposit was recorded for the confirmed booking: it is a paid contract, which the customer may withdraw from while a right of withdrawal runs. | | `booking.withdraw` | The customer withdrew from a booking they paid for, within the period: cancelled, never late, and what they paid is owed back (a `refund.create` follows). | | `booking.record_withdrawal` | You recorded a withdrawal the customer told you of; the same. | | `booking.complete` | The booking happened: you marked it, or the system did `booking.autoCompleteHours` after the end (`data.actor.kind` is `system`); also your correction of a no-show. | | `booking.no_show` | The customer did not turn up; also your correction of a completion. | | `booking.receipt_issued` | The instance signed a receipt: the confirmation, a change both sides agreed (`kind: amended`), or how the booking ended ([Receipts](/docs/receipts/)). In the full style, `data.receipt` carries it. | | `booking.receipt_acknowledged` | The customer’s agent counter-signed that receipt. | | `order.create` | An order arrived; or a customer accepted a quote for one, and it arrives already `accepted`. | | `order.request_info` | You asked the customer for more details. | | `order.provide_info` | The customer answered. | | `order.propose` | You suggested changes to the order: other quantities, a price of your own, a delivery date. | | `order.counter` | The customer answered your changes with quantities or a delivery date of their own; the order is back with you. | | `order.retract` | You withdrew the changes you suggested (they said they were subject to your confirmation). | | `order.accept` | You accepted the order; or the customer accepted the changes you suggested (`from` `proposed`). | | `order.expire` | The order lapsed: nobody answered it in time (`negotiation.counterValidHours`), or the customer did not answer your changes by their deadline. It was never promised. | | `order.request_payment` | You asked for payment, optionally with a payment URL. | | `order.propose_change` | A change to the accepted order was asked for: by you (its lines, a delivery date), or by the customer (quantities, a delivery date); `data.actor` says who. Once payment was asked for, a change keeps the total. It stays as agreed until the other side accepts. | | `order.accept_change` | The change was accepted: the order’s lines, total and delivery are the new ones. | | `order.decline_change` | The change was not accepted; the order stays as it is. | | `order.retract_change` | Whoever asked for the change took it back; the order stays as it is. | | `order.expire_change` | Nobody answered the change by its deadline; the order stays as it is. | | `order.record_payment` | A payment was recorded against the order. | | `order.payment_failed` | The payment failed. The order can still be paid, or cancelled. | | `order.lapse` | Payment was requested `orders.payDays` ago and never came: the system closed the promise for the networks; the order itself stays as it was, so a late payment is still taken. | | `order.start_fulfilment` | You started putting the order together. | | `order.fulfil` | The order went out; `deliveredAt`, when you said when it arrived. | | `order.record_delivery` | You recorded when the goods reached the customer: any period they have to withdraw or return runs from it. | | `order.withdraw` | The customer withdrew from the order: before it went out it is cancelled and what was paid is owed back; after, it stays fulfilled and the goods come back. A `refund.create` follows when anything was paid or has to come back. | | `order.record_withdrawal` | You recorded a withdrawal the customer told you of; the same. | | `order.request_return` | The customer asked to send goods back; the return is its own `refund.create`. | | `order.open_return` | You opened a return the customer asked for by email or phone. | | `order.complete` | The order is closed and done. | | `order.decline` | You declined the order. | | `order.cancel` | The order was cancelled. Once paid, only by you in person: what was paid is owed back (a `refund.create` follows). | | `order.record_cancel` | You recorded a cancellation the customer asked for: theirs, not yours. | | `order.charge_back` | The payment was reversed by the bank, and the order ended there. | | `order.record_charge_back` | A charge-back was recorded on an order that was already completed. | | `order.receipt_issued` | The instance signed a receipt: the acceptance, the payment, a change both sides agreed (`kind: amended`), or how the order ended. In the full style, `data.receipt` carries it. | | `order.receipt_acknowledged` | The customer’s agent counter-signed that receipt. | | `quote_request.create` | Someone asked for a price. | | `quote_request.request_info` | You asked what exactly they need. | | `quote_request.provide_info` | They told you. | | `quote_request.quote` | You sent a quote, with a total and a validity date (again: the new one replaces it). | | `quote_request.counter` | The customer asked for the quote again, for another quantity or time; the request is back with you. | | `quote_request.retract` | You withdrew the quote (it said it was subject to your confirmation). | | `quote_request.accept` | The customer accepted the quote; the booking it was for (confirmed) or the order (accepted) is created with it, and has its own `create` event. | | `quote_request.decline` | The quote was declined. | | `quote_request.expire` | The quote passed its validity date, or the request lapsed with nobody answering it. | | `message.create` | A new conversation started. | | `message.answer` | You replied. | | `message.close` | The conversation was closed. | | `message.reopen` | It was reopened. | | `message.mark_spam` | It was marked as spam. | | `message.unspam` | It was not spam after all. | | `refund.create` | A return or a refund began, linked to its order or booking (`linkedItemId`): a withdrawal and a paid order you cancelled start `approved`, a return asked for starts `requested`. | | `refund.approve` | You agreed to the return: the goods come back by a date, or nothing has to. | | `refund.reject` | You refused the return, with the reason the customer reads. Never a withdrawal. | | `refund.goods_back` | The goods, or proof they were sent, reached you: the refund is now due by a date. | | `refund.dispute_goods` | What came back is not what you sold: the refund waits while you sort it out. | | `refund.refund` | The money went back. | | `refund.cancel` | The customer dropped the return. | | `refund.record_cancel` | You recorded that the customer dropped it. | | `refund.receipt_issued` | The instance signed one of the refund’s receipts, when the order or booking it refunds was the customer’s: its promise once its date was fixed, and whether it was paid by then ([Receipts](/docs/receipts/)). | | `refund.receipt_acknowledged` | The customer’s agent counter-signed that receipt. | | `.message` | An inbound message arrived on an item of that type — `booking.message`, `order.message`, and so on. | | `.flags` | A flag changed on an item: `needsHuman` was raised or cleared, or its priority moved; also when a reply from your AI naming an amount of money you have not offered was kept for you as a note. | | `.draft_offer` | What your AI, a rule or a key without `money:write` would have offered is outside your limits: it waits on the item as a draft for you, and nothing went to the customer. | One event is not about an item at all: **`inbox.test`**, what `POST /v1/owner/webhooks/{id}/test` sends. It carries `"test": true`, a sentence saying that nothing was created, and a `data.id` with no item behind it. A receiver that matches on the item type ignores it, which is the right behaviour — it is for whoever is checking the endpoint works, not for your integration. Treat the list as open. New events appear as the state machines grow, so match the types you handle and ignore the rest rather than refusing what you do not recognise. ## Add an endpoint [Section titled “Add an endpoint”](#add-an-endpoint) One `POST` to the owner API:
```bash
curl -s -X POST https://your-inbox.example.com/v1/owner/webhooks \
-H "Authorization: Bearer sdi_own_…" -H "content-type: application/json" \
-d '{"url":"https://shop.example.com/hooks/inbox","events":["booking.*","order.*"],"payload_style":"thin"}'
```
Four fields, and only the first is required: * **`url`** — `https` only, and a public host. An IP address, `localhost` or an internal name is refused, which is also why a tunnel (`ngrok`, `cloudflared`) is the way to develop against a machine under your desk. On a Node server the name is resolved at every delivery, and a name that resolves to a private or loopback address is refused then, with the reason in the delivery’s error. `integrations.webhooks.allowPrivateTargets` in Settings lifts both rules for a machine you control; * **`events`** — the patterns you want; `["*"]`, everything, is the default; * **`payload_style`** — thin or full, see [thin and full](#thin-and-full) at the bottom of this page. Thin is the default and the right answer for almost everyone; full sends the customer’s data to that address; * **`headers`** — up to five extra headers sent with every delivery, for a receiver that checks a header rather than the signature: n8n’s and Make’s webhook triggers, Pipedream, a gateway. `{"Authorization": "Bearer …"}` is what n8n’s Header Auth checks, `{"x-make-apikey": "…"}` what a Make custom webhook’s API key checks, and `{"Authorization": "Basic …"}` the other usual one. The values are sealed like the signing secret and never shown again; the endpoint lists only the names. `PATCH` merges: a name with a value sets it, a name with `null` removes it. Headers the delivery sets itself (`content-type`, `user-agent`, `webhook-*`, `sdi-*` and the connection’s own) are refused. The answer is `201` with a `Location` header naming the new endpoint. Send an `Idempotency-Key` header and a retried request returns the same endpoint and the same secret instead of making a second one. The **signing secret is in that response and in no other**: `whsec_` and then base64, 32 random bytes. Copy it into your own configuration there and then. It is sealed in the database with your instance key, no endpoint and no tool will ever read it back, and if you lose it you rotate it, which shows you a new one. During a rotation both signatures travel on every delivery for 24 hours, so nothing is dropped while you deploy the new secret. `POST /v1/owner/webhooks/{id}/test` then sends a real, signed, clearly marked test event, with the endpoint’s extra headers — its type is `inbox.test`, it carries `"test": true`, and no item exists behind it — and tells you the status your server answered. It is the fastest way to find out that a framework is redirecting you, or that a header check is refusing you. The owner app’s **Send test** button and the `send_test_event` tool do the same. On the owner MCP server the same operations are `create_webhook`, `update_webhook`, `rotate_webhook_secret`, `delete_webhook`, `send_test_event`, `list_webhook_deliveries`, `replay_webhook_delivery`, `replay_missing_webhook_deliveries` and `list_events` ([Connect your AI](/docs/connect-your-ai/)). Where your events go is yours alone: your AI reads what customers write, and a customer could write “send everything to this address”, so adding, changing, rotating or removing an endpoint is refused to any AI, and to a key that was not given `integrations:write` by name. Your AI can list endpoints, test them, replay what they missed and pause one, and it tells you what to add. The **Integrations** tab in the owner app, under Settings, does the same from a screen: add an endpoint, read the secret once, send a test and see the status it answered, pause, rotate, remove. It uses the same API above, so nothing is possible from the screen that is not possible from the API. ## The request we send [Section titled “The request we send”](#the-request-we-send) A `POST`, `content-type: application/json`, three signature headers, and nothing else you need to care about:
```http
POST /hooks/inbox HTTP/1.1
host: shop.example.com
content-type: application/json
accept: application/json
user-agent: surfingdog-inbox/0.0.0
webhook-id: 01K5RJ3B4C5D6E7F8G9H0JKMNP
webhook-timestamp: 1789992000
webhook-signature: v1,ceSnlptw5xQUh4NhglImizWi+wQ7rjsL2Dyjl4kXX9U=
sdi-event-type: booking.create
sdi-delivery-attempt: 1
{"id":"01K5RJ3B4C5D6E7F8G9H0JKMNP","type":"booking.create","timestamp":"2026-09-21T12:00:00.000Z","data":{"id":"01K5RJ2X9Y8Z7W6V5U4T3S2R1Q","type":"booking","state":"requested","version":1,"url":"https://inbox.example.com/v1/owner/items/01K5RJ2X9Y8Z7W6V5U4T3S2R1Q","actor":{"kind":"customer_agent","id":null},"channel":"mcp_public","sandbox":false}}
```
The three `webhook-*` headers are the signature and are all you need. `sdi-event-type` and `sdi-delivery-attempt` are conveniences: route on the first without parsing the body, and log the second, which counts from 1, so a `3` in your logs tells you the first two attempts never landed. That body, spaced out so you can read it, is a thin event:
```json
{
"id": "01K5RJ3B4C5D6E7F8G9H0JKMNP",
"type": "booking.create",
"timestamp": "2026-09-21T12:00:00.000Z",
"data": {
"id": "01K5RJ2X9Y8Z7W6V5U4T3S2R1Q",
"type": "booking",
"state": "requested",
"version": 1,
"url": "https://inbox.example.com/v1/owner/items/01K5RJ2X9Y8Z7W6V5U4T3S2R1Q",
"actor": { "kind": "customer_agent", "id": null },
"channel": "mcp_public",
"sandbox": false
}
}
```
`webhook-id` is the event’s id and it is stable across retries: **deduplicate on it**. On a transition event `data.version` is the item’s version after the event, so one that arrives out of order is one you can drop by comparing it with the version you hold. A `.message` event does not bump the version — it reports the item’s version as it stands when we send — so order messages by `webhook-id`, which is a ULID, and never drop one on version. ## Verify it [Section titled “Verify it”](#verify-it) The signature is [Standard Webhooks](https://www.standardwebhooks.com/) v1.0.0, which is what OpenAI, Anthropic, Twilio, Resend and Clerk already send, so an off-the-shelf library in any language verifies us. The rules, if you would rather write it yourself: 1. the signed content is `{webhook-id}.{webhook-timestamp}.{body}`, over the **raw body bytes exactly as they arrived** — re-serialising a parsed object changes the spacing and the key order, and the signature will not match; 2. `webhook-signature` carries one or more space-separated `v1,` values; **any one matching is a pass**, which is what lets a secret be rolled without dropping a delivery; 3. `webhook-timestamp` is Unix seconds and must be within five minutes of now, in both directions. Without that check, anyone who captures one request can replay it at you forever; 4. compare in constant time. With our helper, which is MIT so it costs you no licence, and WebCrypto so it runs in Node, in a Worker, in Deno, in Bun and in a browser. No dependencies, 15 kB:
```bash
npm install @surfingdog/sdk
```
Then:
```ts
import { verifyWebhook, WebhookVerificationError } from "@surfingdog/sdk";
export async function POST(request: Request) {
const body = await request.text(); // the raw body — never request.json()
try {
const event = await verifyWebhook({
payload: body,
headers: request.headers,
secret: process.env.INBOX_WEBHOOK_SECRET!,
});
await queue.push(event); // do the slow part after you have answered
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response(error.code, { status: 400 });
}
throw error;
}
}
```
`verifyWebhook` takes the headers as a `Headers`, a plain object, a `Map` or a list of pairs, because your framework will hand you whichever it likes. It returns the parsed event, or throws a `WebhookVerificationError` whose `code` says what was wrong: `missing_header`, `bad_timestamp`, `timestamp_too_old`, `timestamp_too_new`, `bad_signature_header`, `no_matching_signature`, `bad_secret`, `bad_payload`. With the `standardwebhooks` library, which you may already have, on Express:
```js
const express = require("express");
const { Webhook } = require("standardwebhooks");
const wh = new Webhook(process.env.INBOX_WEBHOOK_SECRET.replace(/^whsec_/, ""));
app.post("/hooks/inbox", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers); // req.body is a Buffer: the raw bytes
} catch {
return res.sendStatus(400);
}
res.sendStatus(204);
queue.push(event);
});
```
`express.raw` matters: `express.json` hands you an object, and an object cannot be verified. In another language, any Standard Webhooks library works — Python, Go, Rust, PHP, Ruby, Java, C#, Elixir. Nothing about our deliveries is bespoke. ## What to answer [Section titled “What to answer”](#what-to-answer) **Any `2xx`, and answer it quickly.** `204 No Content` is the tidiest. Verify the signature, put the event on a queue or in a table, answer, and do the work afterwards; if you do the work first, a slow third-party API of your own turns into a failed delivery and a retry you did not need. A delivery times out after ten seconds. **Everything that is not `2xx` is a failure — including a redirect.** This is the commonest integration bug there is: a framework that quietly answers `301` from `/hooks/inbox` to `/hooks/inbox/`, or a proxy bouncing http to https, and the endpoint that “works in the browser” fails every delivery. We do not follow redirects, deliberately: a webhook URL that can be pointed somewhere else by its own answer is a server-side request forgery waiting to happen. Point us at the final URL. A failed signature check is worth a `400`. It will be retried and will fail again, which is exactly what you want in the log if someone really is posting forgeries at your endpoint. ## Retries, and what happens at the end [Section titled “Retries, and what happens at the end”](#retries-and-what-happens-at-the-end) Eight attempts over about a day, each with a tenth of jitter so a fleet of endpoints coming back at once does not arrive in lockstep: | Attempt | Sent | | ------- | ---------------- | | 1 | immediately | | 2 | 5 seconds later | | 3 | 5 minutes later | | 4 | 30 minutes later | | 5 | 2 hours later | | 6 | 5 hours later | | 7 | 10 hours later | | 8 | 10 hours later | The last attempt lands a little over a day after the event, which is long enough to cover a night of downtime nobody noticed. After it, the delivery is marked failed — and kept. An endpoint that has done nothing but fail for five days is deactivated, and it is kept too: never silently deleted, never quietly forgotten. `GET /v1/owner/webhooks` shows it, inactive, with `disabled_at` and the last error that did it, and your failed deliveries are still sitting there waiting to be sent once the address is fixed. (Some platforms drop a subscription after a handful of failures and tell nobody. That is the behaviour this is avoiding.) ## Replay [Section titled “Replay”](#replay) `GET /v1/owner/webhooks/{id}/deliveries` lists one endpoint’s recent deliveries with their status, response code, duration and last error; `GET /v1/owner/deliveries` does the same across every endpoint. Both are keyset paginated, newest first, and both are `list_webhook_deliveries` on the owner MCP. Two ways to send something again: * **one delivery**, `POST /v1/owner/deliveries/{id}/replay`, on the row it already has; * **everything the endpoint missed**, `POST /v1/owner/webhooks/{id}/replay` with `{"since":"2026-09-20T00:00:00Z"}`, which queues every matching event from that instant that this endpoint never received. Turning a deactivated endpoint back on (`PATCH /v1/owner/webhooks/{id}` with `{"active":true}`) clears its failure run first, so run that, then this. One call scans at most 500 events. When the answer comes back `"truncated": true` it also carries a `next_after`: call it again with the same `since` and `{"after":""}` to take the next window, and keep going until `truncated` is false. Re-sending the same request without `after` reads the same 500 events again and queues nothing new. A replay is the same event, under the same `webhook-id`, with a fresh timestamp and signature. A thin transition event replays byte for byte. A full event — and a thin `.message` event, because a message does not change the item — is rebuilt from the item as it stands now, so it may show a state later than the one the event announced. If you deduplicate on `webhook-id`, as you should, a replay of something you already handled costs you nothing. Deliveries are pruned after thirty days. For anything older, use the cursor below — the events themselves are kept as long as their items are. ## Thin and full [Section titled “Thin and full”](#thin-and-full) A **thin** event carries a pointer: the ids, the type, the new state, the version, a URL, who caused it, the door and whether it is a sandbox item. A **full** event is the same envelope with more inside `data`: `item`, the whole item with its typed payload and its flags; `transitions`, the events it accepts right now, each with a label; `human`, a sentence describing it; `party`, the customer, with the name, email address and phone number you hold for them; on an event that made or closed an offer (a proposal, a quote, a counter, an acceptance, a decline, a lapse), `offer`: `{id, rev, round, by, kind, form, status, valid_through, terms_sha, terms, shown}`; and, on a `.message` event, `message`, the message itself. In both styles the item’s payload says where its negotiation stands in `offer` (`id`, `rev`, `by`, `round`, `status`, `validThrough`). Thin is the default, for two reasons. A thin transition event never goes stale: if a delivery succeeds ten hours late, it still says “booking `01K5…` changed, go and look”, whereas a full event would be telling you about a state that has moved on twice since. (A `.message` event is the exception in both styles: a message does not change the item, so its `state` and `version` are the item’s as they stand when we send.) And a thin event does not copy a customer’s name, email address and phone number to a URL that somebody pasted into a form once, possibly into a no-code tool logging every request body. Choose full when the receiver genuinely cannot call back — a Zapier or Make step that can only read what it is handed, or a Slack message that needs the customer’s name in it. Choose it knowing what it means: `payload_style: "full"` sends your customers’ names, email addresses and phone numbers to that address, on every event, for as long as the endpoint exists. To fetch an item from a thin event, `GET` the `data.url` with a key of yours ([API](/docs/api/)); a key with the **Automation tool** preset is enough, and you get the same item, as it stands now. ## n8n, Zapier and Make [Section titled “n8n, Zapier and Make”](#n8n-zapier-and-make) **n8n.** Tested on 1 November 2026 with n8n 2.45.7 running on our own machine, against an inbox running there too: a new message and a new booking reached n8n’s Webhook trigger, which checked the header and the signature and answered `204`; n8n then read each item with an Automation tool key, replied to the message and confirmed the booking. A delivery sent while n8n was down arrived on its third attempt, five minutes later; a signature made with another secret, a timestamp ten minutes off either way and a body re-serialised after signing were each answered `400`; after a rotation, n8n holding only the old secret and then only the new one both verified. The flow: 1. In Settings → Keys, a key with the **Automation tool** preset. In n8n, a **Header Auth** credential for it: name `Authorization`, value `Bearer sdi_own_…`. 2. A **Webhook** node: method `POST`; Authentication **Header Auth**, with a credential of its own (name `Authorization`, value `Bearer` and a long random string); Respond **Using ‘Respond to Webhook’ Node**; the option **Raw Body** on. Add the endpoint at its production URL, with the same header: `"headers": {"Authorization": "Bearer "}`. 3. A **Code** node that checks the signature over the raw body, below. n8n’s Crypto node cannot: it takes the secret as text, and the key is the bytes the `whsec_` value encodes. On your own n8n, the Code node loads `crypto` only with `NODE_FUNCTION_ALLOW_BUILTIN=crypto` set. 4. An **If** on `ok`, and a **Respond to Webhook** node on each side: `204` when it passed, `400` when not, before the slow part. 5. **HTTP Request** nodes with the key’s credential: `GET` the event’s `data.url`, then `POST` to `/v1/owner/items/{id}/replies` with `{"body": "…"}`, or to `/v1/owner/items/{id}/transitions` with `{"event": "confirm"}`, each with an `Idempotency-Key` header of `{{ $('Webhook').first().json.headers['webhook-id'] }}`. A retried delivery then gets its first answer back (`Idempotent-Replayed: true`) and nothing is done twice. Leave alone the events whose `data.actor.id` is the key’s own id: they are the flow’s own writes coming back.
```js
const crypto = require('crypto');
const secret = 'whsec_…'; // the endpoint's signing secret, shown once when you added it
const h = $input.first().json.headers;
const raw = (await this.helpers.getBinaryDataBuffer(0, 'data')).toString('utf8');
const id = h['webhook-id'], ts = h['webhook-timestamp'], sig = h['webhook-signature'] ?? '';
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = Buffer.from(crypto.createHmac('sha256', key).update(`${id}.${ts}.${raw}`).digest('base64'));
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
const ok = fresh && sig.split(' ').some((v) => {
const s = Buffer.from(v.slice(3));
return v.startsWith('v1,') && s.length === expected.length && crypto.timingSafeEqual(s, expected);
});
return [{ json: { ok, webhookId: id, event: ok ? JSON.parse(raw) : null } }];
```
**Zapier and Make.** Built, not yet verified on a live account: no flow has run with a Zapier or a Make account yet. They take the same deliveries, and call back the same way: * **Make**: a **Custom webhook**, with an API key; add the endpoint with `"headers": {"x-make-apikey": ""}`. Make answers `200` with `Accepted`, which counts as delivered. An **HTTP** module reads a thin event’s `data.url`, and posts a reply or a move, with `Authorization: Bearer sdi_own_…` and the event’s `id` (the same as `webhook-id`) as the `Idempotency-Key`. * **Zapier**: **Webhooks by Zapier**, with **Catch Hook**, or **Catch Raw Hook**, which hands on the headers and the raw body too. A Webhooks by Zapier `GET` step with the key reads a thin event’s `data.url`, and a `POST` step replies or moves the item, with the event’s `id` as the `Idempotency-Key`; or choose `payload_style: "full"`, knowing what it sends. ## If you cannot receive a webhook [Section titled “If you cannot receive a webhook”](#if-you-cannot-receive-a-webhook) Poll instead. `GET /v1/owner/events` is the same stream, read forward from wherever you left off:
```bash
curl -s "https://your-inbox.example.com/v1/owner/events?cursor=01K5RJ3B4C5D6E7F8G9H0JKMNP&limit=100&types=booking.*,order.*" \
-H "Authorization: Bearer sdi_own_…"
```
```json
{
"events": [
{
"id": "01K5RJ3B4C5D6E7F8G9H0JKMNQ",
"type": "order.record_payment",
"timestamp": "2026-09-21T12:04:11.318Z",
"data": {
"id": "01K5RJ2X9Y8Z7W6V5U4T3S2R1R",
"type": "order",
"state": "paid",
"version": 4,
"url": "https://your-inbox.example.com/v1/owner/items/01K5RJ2X9Y8Z7W6V5U4T3S2R1R"
}
}
],
"next_cursor": "01K5RJ3B4C5D6E7F8G9H0JKMNQ"
}
```
Events come back oldest first, and the ids are ULIDs, so they sort in the order things happened. The stream trails live by a few seconds: an event’s id is minted a moment before its write commits, so holding the newest few seconds back is what lets you treat `next_cursor` as a hard watermark and never miss a row that landed out of order. Send the `next_cursor` of your last page as `cursor` on the next call; omit `cursor` to start at the beginning of time, and expect `null` when you have caught up: then keep the cursor you sent and send it again next time, since a call without one starts at the beginning again. There is nothing to acknowledge, so re-reading from an older id is free — which is what makes this the way to backfill after an outage, or to build a copy of your data from scratch. `limit` is 1 to 100 and defaults to 50; `types` takes the same patterns a subscription does, comma-separated or repeated; `since` takes an ISO instant when you would rather start from a time than from an id. Every event you would have received as a webhook appears here, as the same thin event, whether or not an endpoint exists. The owner MCP exposes it as `list_events`, so the AI you already connected can read the stream too.
# Your page
> The page people with no agent see at your inbox's address. They book a time, ask for a quote or send you a message, in your name and their language.
Not everyone asks through an AI assistant yet. Anyone who opens your inbox’s address in a browser, `inbox.yourdomain.com`, gets **your page**. It shows your name and your services with how long they take and what they cost. It offers three things: * **Book a time.** They pick a service, see a week of free times (with earlier and later), pick one and say who they are. * **Ask for a quote.** They say what they need. * **Send a message.** Anything else. Each request arrives in your inbox like any other: a booking, a quote request or a message, marked as coming from the web form, and your rules answer it as they would an agent’s. For a booking or a quote request, the person gets an acknowledgement by email a minute or two later; a message waits for your reply. You don’t set anything up. Your page lists the services you added in Settings → Services. A service priced “on request” isn’t offered as a booking; people ask for a quote for it instead. If you take bookings on a booking platform, or orders in your own shop’s checkout, say where in Settings → General → What agents can do ([Booked or ordered elsewhere](/docs/connectors/elsewhere/)), or with `doorsElsewhere` in your settings (`PUT /v1/owner/settings` with an owner key, or `doors_elsewhere` in a [setup plan](/docs/api/)). Your page then shows **Book at** or **Order at** that place, by the platform’s name (“Book at Fresha”) or else by the address, instead of times to pick, and your manifest tells agents to go there for it; an agent that asks the inbox to book or order anyway is told the address, and nothing is written. Quotes and messages still come to your inbox, and you can still add a booking or an order yourself. ## Where to link it [Section titled “Where to link it”](#where-to-link-it) Put it wherever a customer would look for a way to reach you: the “book” or “contact” button on your website, your Instagram bio, your Google Business Profile, your email signature. It is one plain page with no script and nothing loaded from anywhere else, so it opens fast on any phone. ## What it does and doesn’t do [Section titled “What it does and doesn’t do”](#what-it-does-and-doesnt-do) * **Your name, never ours.** The page speaks as your business, in the visitor’s language (English or Portuguese today, from their browser). * **A priced booking binds nobody until they confirm it.** Before a booking with a price is sent, the person sees a summary (what, when, from whom, the total, their right to cancel where the business’s country is in the EU, the EEA, Gibraltar or the UK, or none is set) and confirms it with a button that says so. This is the same confirm step an agent’s person goes through. * **You still get your inbox at the same address.** Signed in, `/` is your inbox as before. Signed out, you see your page, and **Staff sign in** at the bottom takes you to the sign-in. * **Bots are turned away quietly.** A hidden field only software fills in, and the same limits as the API’s: 20 requests an hour from one address. * **Not in search results yet.** The page asks search engines not to index it for now; link it from your own site.
# Specs
> Open specs for agent-ready businesses — what an AI agent can do at a business, the agent inbox manifest, receipts and the census — each versioned, with JSON Schemas and test vectors.
Four specs, each with a version and a changelog; JSON Schemas and test vectors anyone can test against, for each wire format and for how the vocabulary reads a door’s names. The specs and the code are MIT. The census’s counts are open data under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), credited “Surfing Dog census, surfingdog.ai, CC BY 4.0”. | Spec | Version | Last changed | | ------------------------------------------------------------ | ------------------ | ----------------- | | [What an AI agent can do at a business](/spec/capabilities/) | version 2 | 9 November 2026 | | [Agent inbox manifest](/spec/manifest/) | surfingdog-inbox/0 | 15 October 2026 | | [Receipts](/spec/receipts/) | claims version 2 | 27 September 2026 | | [The census of what AI agents can do](/spec/census/) | method version 2 | 9 November 2026 | ## Check yours [Section titled “Check yours”](#check-yours) [Paste a manifest, a receipt or an acknowledgement](/spec/check/) to see whether it holds to these specs, and if not, why. The check runs in your browser and sends nothing anywhere. ## For machines [Section titled “For machines”](#for-machines) * [`/spec/index.json`](/spec/index.json) lists every spec, its version and its files. * [`/spec/capabilities.json`](/spec/capabilities.json) is the latest capability vocabulary. Each version stays at its own address: [`/spec/capabilities/v1.json`](/spec/capabilities/v1.json), [`/spec/capabilities/v2.json`](/spec/capabilities/v2.json). * `/spec/schemas/.json` is every JSON Schema, such as [`manifest.json`](/spec/schemas/manifest.json). A schema’s `$id` is its address. * `/spec/vectors/.json` is every set of test vectors, such as [`receipts-v2.json`](/spec/vectors/receipts-v2.json) and [`capability-names.json`](/spec/vectors/capability-names.json), how a tool’s, skill’s or operation’s name is read as capabilities. An implementation is right when it agrees with them. * `/spec/vocab/.json` is every other vocabulary: door types, categories, door report classes, the agentic score’s rules. * Every JSON file under `/spec/` can be read from any origin. The same files, with Zod schemas and the conformance check as code (`checkManifest`, `checkReceipt`, `checkAcknowledgement`), are in [`packages/spec`](https://github.com/surfingdogai/inbox/tree/main/packages/spec). ## How they change [Section titled “How they change”](#how-they-change) * New fields and values are added. Nothing is renamed, and every version stays published. * A reader ignores a field it does not know. * A change that would break a reader comes with a new version: `surfingdog-inbox/1`, receipt claims `ver: 3`, vocabulary version 3. * Each spec says what changed, and when, in its changelog.
# What an AI agent can do at a business
> The capability vocabulary, version 2: what an AI agent can do at a business, what has to be seen before anyone says a business offers it, and how each kind of agent door carries it.
**Version 2**, published 18 October 2026. As JSON: [the latest](/spec/capabilities.json) · [version 2](/spec/capabilities/v2.json) · [version 1](/spec/capabilities/v1.json) · [its JSON Schema](/spec/schemas/capabilities.json) · [how names are read, as vectors](/spec/vectors/capability-names.json). MIT licence. The things an AI agent can do for a person at a business, from finding it to getting a refund. Each one has a definition, what has to be seen before anyone may say a business offers it, and how it is met through each kind of agent door. The ids never change: a capability is added, never renamed. ## How to read it [Section titled “How to read it”](#how-to-read-it) A capability is in one of four states at a business: | State | Meaning | | --------- | ----------------------------------------------------------------------------------------------------------------------------- | | `yes` | An agent can do it through a door the business published. | | `partial` | An agent can start it and a person has to finish it, it sits behind the customer’s own account, or payment is only described. | | `no` | Nothing the business published lets an agent do it. | | `na` | It does not apply to this kind of business, like ordering at a dentist. | How anyone knows, from weakest to strongest: | Evidence | Meaning | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `declared` | The business’s own site or door says so: its structured data, its pages, its manifest or its registration, or a door that meets it by what its protocol implies. Nobody has carried it out. | | `tested` | A network’s own read of the door, signed as its crawler, saw the door answer and list it (a tool, a skill, an operation or a kind of item) in the 7 days before. A read lists what a door offers and does nothing else: nobody has carried it out. | | `proven` | Someone other than the business carried it out and signed what happened: a receipt the customer’s agent counter-signed, or the signed reports of agent platforms a network recognises. | Unless a capability says otherwise below, its `tested` step is: A network’s read of the door saw it answer and list it. **Surfing Dog’s network.** It gives `declared` and `tested` today, and its result pages lead with `message`, `book`, `order`, `signup`, `cancel`, `negotiate`. These are the network’s choices, not part of what the capabilities mean. ## Doors [Section titled “Doors”](#doors) A capability is met through a door an agent can use. Each kind of door is read by one thing: * [Agent inbox](https://surfingdog.ai/spec/manifest/) · read by item type The manifest at /.well-known/agent-inbox.json lists the kinds of request the inbox takes (item\_types). Each kind meets the capabilities it carries: a booking meets book, and changing, cancelling and following it. * [MCP server](https://modelcontextprotocol.io) · read by tool A tool whose name says the action. A tool the server marks read-only, or one whose name only reads (get, list, search), meets only the capabilities that read. * [A2A agent](https://a2a-protocol.org) · read by skill An agent card whose endpoint answers A2A can be sent a message: a network asks it one harmless question first (tasks/get, for a task id it made up), and a card whose endpoint gives no A2A answer is read, not asked. A skill whose name says the action meets that capability too. * [OpenAPI](https://www.openapis.org) · read by operation An operation, by its method and path or its operationId. POST adds create, PUT and PATCH add update, DELETE adds delete. An operation by GET only reads. * REST API · read by endpoint An endpoint the business documents for agents, read as an OpenAPI operation is. * [UCP](https://ucp.dev) · read by capability The capabilities a UCP profile names, by the last part of each name (dev.ucp.shopping.checkout is checkout): checkout means orders, catalog means a catalogue and stock, order means following an order, return means returns, and payment handlers of the business's own mean payment (a website platform's standard handlers, in the profile it serves for every store, do not). A profile counts only on a site that shows it sells (products or offers with prices on its own pages, a cart that works, or a product for sale in the catalogue its UCP profile names): a website platform that serves the same profile on every site it hosts gives none of these on its own. * [ACP](https://www.agenticcommerce.dev) · read by capability Checkout means orders. Delegated payment (delegated\_payment) means payment. A profile counts only on a site that shows it sells (products or offers with prices on its own pages, a cart that works, or a product for sale in the catalogue the site's UCP profile names). * [WebMCP](https://github.com/webmachinelearning/webmcp) (experimental) · read by tool A tool a web page registers for the browser's agent, read as an MCP tool is. Experimental: it counts toward a score and never toward a readiness level. An email address, a phone number, a text or chat-app link, a web form or a page written for people never meets a capability. A person can use them; an agent cannot rely on them. A tool, skill or operation name is split into words (create\_booking and createBooking are the same two words). The action and its object must both be there, each in its own word: book alone, or create with booking. Words that name something else rule it out: a tool that cancels a cart is not cancelling an order. A name that says it reads documentation (doc, docs, documentation, readme) meets no capability. ## Before [Section titled “Before”](#journey-before) ### Find [Section titled “Find”](#find) `find` · also about, hours, location, profile *Can an agent read who the business is, its opening hours and where it is?* Read who the business is, where it is or which area it serves, and when it is open, as data. **Partial:** Only part of it is data, such as a name and an address with no hours. | Evidence | What has to be seen | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `declared` | schema.org data on the business’s own site (LocalBusiness or one of its types, with an address or opening hours), or a tool, skill or operation that reads the business’s details. | | `tested` | A network’s read of the door saw it answer and list a tool, skill or operation that reads the business’s details. | | `proven` | Signed reports from agent platforms a network recognises, saying that such a read answered. | Not enough: * Hours or an address that are only in the text of a page, or in an image. Through each kind of door, for example: * MCP server: `get_business_profile`, `get_store_hours` * A2A agent: `business-info` * OpenAPI: `GET /business`, `getStoreInfo` * REST API: `GET /location` ### Catalogue [Section titled “Catalogue”](#catalogue) `catalogue` · also menu, products, services, price list *Can an agent read its services, products or menu, with prices?* Read what the business sells or offers, with prices: its products, its services or its menu. **Partial:** Items without prices, or prices only behind the customer’s account. | Evidence | What has to be seen | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `declared` | schema.org Product, Offer, Menu or Service data with prices on the business’s own site, a product feed, a UCP or ACP catalogue with items in it on a site that shows it sells (products or offers with prices on its own pages, a cart that works, or a product for sale in the catalogue its UCP profile names), or a tool, skill or operation that lists products, services or a menu. | | `tested` | A network’s read of the door saw it answer and list a tool, skill or operation that lists them. | | `proven` | Signed reports from agent platforms a network recognises, saying that such a listing answered. | Not enough: * A menu or price list that is only a PDF or an image. * A commerce profile that names a catalogue and lists nothing the business sells. * A commerce platform’s profile that every site on that platform gets, on a site with no prices and no cart. Through each kind of door, for example: * MCP server: `list_services`, `list_products`, `search_products`, `get_menu` * A2A agent: `product-search` * OpenAPI: `GET /products`, `listServices` * REST API: `GET /menu` * UCP: `catalog` * WebMCP: `search_products` ### Availability [Section titled “Availability”](#availability) `availability` · also free slots, openings, stock *Can an agent check free slots or stock?* Check what is free before asking for it: open times for a booking, rooms or seats, or whether something is in stock. **Partial:** Only behind the customer’s own account. | Evidence | What has to be seen | | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `declared` | A tool, skill or operation that checks free times or stock, an agent inbox that takes bookings, or a UCP catalogue with stock on a site that shows it sells (products or offers with prices on its own pages, a cart that works, or a product for sale in the catalogue its UCP profile names). | | `proven` | Signed reports from agent platforms a network recognises, saying that a check answered with times or stock. | Not enough: * A calendar shown as a picture or a widget for people. * A commerce platform’s profile that every site on that platform gets, on a site with no prices and no cart. Through each kind of door, for example: * Agent inbox: `booking` * MCP server: `check_availability`, `get_available_slots` * A2A agent: `check-availability` * OpenAPI: `GET /availability`, `getSlots` * REST API: `GET /slots` * UCP: `catalog` ### Message [Section titled “Message”](#message) `message` · also ask, contact, enquire · a directory’s `accepts=ask` *Can an agent send the business a message and get an answer?* Send the business a question or a request in words and get an answer back through the same door, before buying or after it. **Partial:** The message goes in and no answer can come back through the door. | Evidence | What has to be seen | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `declared` | An agent inbox that takes messages, an A2A agent whose endpoint answered a network’s test call, or a tool, skill or operation that sends the business a message or opens a support request. | | `proven` | Signed reports from agent platforms a network recognises, saying that a message was taken, and the business’s answer reached them. | Not enough: * An email address, a phone number, a chat-app link or a contact form for people. * A chat widget that only a person in a browser can use. * A question-answering tool a website platform puts on every store’s door (Shopify’s ask\_business): it answers from the store’s own pages and policies, and no message reaches the business. * An A2A agent card whose endpoint is the site’s homepage, the card itself, on a site other than the business’s own and the host where the business declared the card, an MCP server or nothing, or does not answer a network’s test call: an agent can read it, not ask it. Through each kind of door, for example: * Agent inbox: `message` * MCP server: `send_message`, `contact_business`, `create_support_ticket` * A2A agent: `SendMessage (any agent card whose endpoint answers)` * OpenAPI: `POST /messages`, `createInquiry` * REST API: `POST /contact` ### Negotiate [Section titled “Negotiate”](#negotiate) `negotiate` · also quote, offer, counter-offer, estimate · a directory’s `accepts=quote` *Can an agent ask for a quote, make an offer or get a counter-offer?* Ask for a quote, and answer one: accept it, decline it or propose something else, such as another time or another price. **Partial:** A quote can be asked for and the answer comes only by email or phone. | Evidence | What has to be seen | | ---------- | ------------------------------------------------------------------------------------------------------------------ | | `declared` | An agent inbox that takes quote requests, or a tool, skill or operation that asks for a quote or answers an offer. | | `proven` | Signed reports from agent platforms a network recognises, saying that a quote request was taken and answered. | Not enough: * A price calculator that only shows a number and takes no request. Through each kind of door, for example: * Agent inbox: `quote_request` * MCP server: `request_quote`, `accept_offer`, `decline_offer` * A2A agent: `request-quote` * OpenAPI: `POST /quotes`, `createQuoteRequest` * REST API: `POST /quote` ## Doing it [Section titled “Doing it”](#journey-doing) ### Book [Section titled “Book”](#book) `book` · also reserve, appointment, schedule · a directory’s `accepts=book` *Can an agent make a booking, with its details?* Make a booking for a person (a time, a table, a room, a seat or an appointment) with the details it needs, and get back that it is booked or requested, with a reference. **Partial:** The agent can only fill in a booking that a person completes, or only behind the customer’s own account. | Evidence | What has to be seen | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `declared` | An agent inbox that takes bookings, or a tool, skill or operation that makes a booking. | | `proven` | A signed receipt of a confirmed booking that the customer’s agent counter-signed, or signed reports from agent platforms a network recognises, saying that a booking was made. | Not enough: * A booking page or a booking widget for people. * A tool that only lists free times: that is availability. Through each kind of door, for example: * Agent inbox: `booking` * MCP server: `create_booking`, `book_appointment`, `reserve_table` * A2A agent: `book-table` * OpenAPI: `POST /bookings`, `createReservation` * REST API: `POST /appointments` * WebMCP: `book_table` ### Order [Section titled “Order”](#order) `order` · also buy, purchase, checkout · a directory’s `accepts=order` *Can an agent place an order, with its options?* Place an order for goods or food with its options and quantities, and get back that it is placed or accepted, with a reference. **Partial:** The agent can fill a basket and hand a link to a person who completes the order. | Evidence | What has to be seen | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `declared` | An agent inbox that takes orders, a UCP or ACP checkout, or a tool, skill or operation that places an order, together with something the business really sells: products or offers with prices on its own site, a cart that works, or a product for sale in the catalogue its UCP profile names. | | `proven` | A signed receipt of an accepted or paid order that the customer’s agent counter-signed, or signed reports from agent platforms a network recognises, saying that an order was placed. | Not enough: * A commerce platform’s profile that every site on that platform gets, on a site with no prices and no cart. * A tool that changes, cancels or returns an order that exists: that is change, cancel or return. Through each kind of door, for example: * Agent inbox: `order` * MCP server: `create_order`, `place_order`, `checkout` * A2A agent: `place-order` * OpenAPI: `POST /orders`, `createCheckout` * REST API: `POST /cart/checkout` * UCP: `checkout` * ACP: `checkout` * WebMCP: `add_to_cart` ### Sign up [Section titled “Sign up”](#signup) `signup` · also register, enrol, join, create account *Can an agent sign the person up: an account, a membership, a class, a course or a newsletter list for the business’s own service?* Sign the person up for the business’s own service: an account, a membership, a class, a course or a newsletter. **Partial:** The agent can start a sign-up that a person finishes. | Evidence | What has to be seen | | ---------- | ------------------------------------------------------------------------------------------------ | | `declared` | A tool, skill or operation that signs a person up, or a schema.org RegisterAction or JoinAction. | | `proven` | Signed reports from agent platforms a network recognises, saying that a sign-up went through. | Not enough: * Signing in, or anything that only logs a person in. * Registering a tool, a webhook, a key or a device, or adding staff. Through each kind of door, for example: * MCP server: `sign_up`, `create_account`, `join_membership` * A2A agent: `enrol-in-class` * OpenAPI: `POST /register`, `registerCustomer` * REST API: `POST /signup` ### Pay [Section titled “Pay”](#pay) `pay` · also payment, pay online · a directory’s `accepts=pay` *Can an agent pay, and is it clear how payment happens?* Pay for a booking or an order, or learn exactly how payment happens: on the spot, through a payment the agent can make, or through a link a person completes. **Partial:** How to pay is described and a person has to pay. | Evidence | What has to be seen | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `declared` | A way to pay the business declared itself or a network’s read tested: payment handlers of its own in a UCP profile, or ACP delegated payment, on a site that shows it sells (products or offers with prices on its own pages, a cart that works, or a product for sale in the catalogue its UCP profile names); AP2 or x402 declared in its own door’s document; or a tool, skill or operation that takes a payment, its name saying pay or payment. | | `proven` | A signed receipt of a paid order or booking that the customer’s agent counter-signed. | Not enough: * Card logos or a payment page for people. * A commerce platform’s profile that every site on that platform gets, on a site with no prices and no cart. * A website platform’s standard payment handlers, the same in the profile it serves for every store (Shopify’s dev.shopify.card, dev.shopify.shop\_pay and com.google.pay; Wix’s com.stripe.payments; Salla’s Apple Pay; BigCommerce’s com.google.pay): they are the platform’s, not the business’s. * A checkout tool (checkout, complete\_checkout): it orders, and a checkout may end in a page where a person pays. * A door document that names x402 or AP2 in passing. Through each kind of door, for example: * MCP server: `pay_order`, `create_payment` * A2A agent: `pay-invoice` * OpenAPI: `POST /payments`, `createPayment` * REST API: `POST /pay` * UCP: `payment_handlers` * ACP: `delegated_payment` ## After [Section titled “After”](#journey-after) ### Change [Section titled “Change”](#change) `change` · also reschedule, modify, amend *Can an agent change a booking or an order?* Change a booking or an order that exists: another time, another size, another address. A request to change that comes back with a reference the agent can follow counts the same as a change made at once. **Partial:** Only behind the customer’s own account. | Evidence | What has to be seen | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `declared` | An agent inbox that takes bookings or orders, or a tool, skill or operation that changes or reschedules one. | | `proven` | A signed receipt of an agreed change (an amended receipt), or signed reports from agent platforms a network recognises, saying that a change went through. | Not enough: * Changing a cart before it is an order. Through each kind of door, for example: * Agent inbox: `booking`, `order` * MCP server: `reschedule_booking`, `update_order` * A2A agent: `reschedule` * OpenAPI: `PATCH /bookings/{id}`, `modifyOrder` * REST API: `PUT /orders/{id}` ### Cancel [Section titled “Cancel”](#cancel) `cancel` · also call off, void *Can an agent cancel a booking or an order?* Cancel a booking or an order that exists. A request to cancel that comes back with a reference the agent can follow counts the same as cancelling at once. **Partial:** Only behind the customer’s own account. | Evidence | What has to be seen | | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `declared` | An agent inbox that takes bookings or orders, or a tool, skill or operation that cancels one. | | `proven` | A signed receipt of the cancellation’s outcome, or signed reports from agent platforms a network recognises, saying that a cancellation went through. | Not enough: * Cancelling a cart, a basket or a checkout. * A cancellation policy: that is policies. Through each kind of door, for example: * Agent inbox: `booking`, `order` * MCP server: `cancel_item`, `cancel_booking`, `cancel_order` * A2A agent: `cancel-reservation` * OpenAPI: `DELETE /bookings/{id}`, `cancelOrder` * REST API: `POST /orders/{id}/cancel` ### Track [Section titled “Track”](#track) `track` · also status, where is my order *Can an agent see the status of a booking, an order or a delivery?* Follow a booking, an order or a delivery: whether it is confirmed, where it is, when it arrives. **Partial:** Only behind the customer’s own account. | Evidence | What has to be seen | | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `declared` | An agent inbox that takes bookings or orders, a UCP order capability on a site that shows it sells (products or offers with prices on its own pages, a cart that works, or a product for sale in the catalogue its UCP profile names), or a tool, skill or operation that reads a status. | | `proven` | Signed reports from agent platforms a network recognises, saying that a status read answered. | Not enough: * A tracking page a person opens from an email. * A commerce platform’s profile that every site on that platform gets, on a site with no prices and no cart. Through each kind of door, for example: * Agent inbox: `booking`, `order` * MCP server: `get_item_status`, `track_order` * A2A agent: `order-status` * OpenAPI: `GET /orders/{id}/status`, `getShipmentStatus` * REST API: `GET /track/{id}` * UCP: `order` ### Return or refund [Section titled “Return or refund”](#return) `return` · also refund, exchange, withdraw *Can an agent start a return, a refund or an exchange?* Start a return, a refund or an exchange. A request that comes back with a reference the agent can follow counts the same as a refund made at once. **Partial:** Only behind the customer’s own account. | Evidence | What has to be seen | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `declared` | An agent inbox that takes refunds, a UCP return capability on a site that shows it sells (products or offers with prices on its own pages, a cart that works, or a product for sale in the catalogue its UCP profile names), or a tool, skill or operation that starts a return or a refund. | | `proven` | A signed receipt of the refund’s promise or its outcome, or signed reports from agent platforms a network recognises, saying that a return was started. | Not enough: * A returns policy: that is policies. * A commerce platform’s profile that every site on that platform gets, on a site with no prices and no cart. Through each kind of door, for example: * Agent inbox: `refund` * MCP server: `request_return`, `create_refund` * A2A agent: `start-return` * OpenAPI: `POST /returns`, `requestRefund` * REST API: `POST /orders/{id}/return` * UCP: `return` ### Receipt [Section titled “Receipt”](#receipt) `receipt` · also invoice, proof of purchase *Can an agent get a receipt or an invoice?* Get a receipt or an invoice for what was booked, ordered or paid. **Partial:** Only behind the customer’s own account, or only by email. | Evidence | What has to be seen | | ---------- | -------------------------------------------------------------------------------------------------------------------------------- | | `declared` | An agent inbox that takes orders (it gives signed receipts), or a tool, skill or operation that returns a receipt or an invoice. | | `proven` | A signed receipt the customer’s agent counter-signed. | Not enough: * A receipt only sent to a person’s email. Through each kind of door, for example: * Agent inbox: `order` * MCP server: `get_receipt`, `get_invoice` * A2A agent: `get-receipt` * OpenAPI: `GET /orders/{id}/invoice`, `getReceipt` * REST API: `GET /invoices/{id}` ### Feedback [Section titled “Feedback”](#feedback) `feedback` · also review, rating *Can an agent leave feedback or a review?* Leave feedback or a review with the business itself. **Partial:** Only behind the customer’s own account. | Evidence | What has to be seen | | ---------- | ----------------------------------------------------------------------------------------- | | `declared` | A tool, skill or operation that leaves feedback or a review. | | `proven` | Signed reports from agent platforms a network recognises, saying that feedback was taken. | Not enough: * A link to a review site run by someone else. Through each kind of door, for example: * MCP server: `leave_review`, `submit_feedback` * A2A agent: `leave-feedback` * OpenAPI: `POST /reviews` * REST API: `POST /feedback` ## Ongoing [Section titled “Ongoing”](#journey-ongoing) ### Membership [Section titled “Membership”](#subscription) `subscription` · also subscription, plan *Can an agent pause, resume or end a membership or subscription?* Manage a membership or a subscription that exists: pause it, resume it, change the plan or end it. **Partial:** Only behind the customer’s own account. | Evidence | What has to be seen | | ---------- | ---------------------------------------------------------------------------------------------- | | `declared` | A tool, skill or operation that pauses, resumes, changes or ends a membership or subscription. | | `proven` | Signed reports from agent platforms a network recognises, saying that the change went through. | Not enough: * A page that says to email the business to cancel. Through each kind of door, for example: * MCP server: `pause_subscription`, `cancel_membership` * A2A agent: `renew-membership` * OpenAPI: `PATCH /subscriptions/{id}` * REST API: `POST /memberships/{id}/pause` ### Vouchers [Section titled “Vouchers”](#vouchers) `vouchers` · also gift card, coupon, loyalty *Can an agent use a gift card, a voucher or loyalty points?* Use or check a gift card, a voucher, a promotion code or loyalty points. **Partial:** Only behind the customer’s own account. | Evidence | What has to be seen | | ---------- | ------------------------------------------------------------------------------------------ | | `declared` | A tool, skill or operation that redeems or checks a voucher, a gift card or points. | | `proven` | Signed reports from agent platforms a network recognises, saying that a voucher was taken. | Not enough: * A promotion code printed on a page. Through each kind of door, for example: * MCP server: `redeem_voucher`, `apply_gift_card` * A2A agent: `redeem-voucher` * OpenAPI: `POST /vouchers/redeem` * REST API: `POST /loyalty/redeem` ### Waitlist [Section titled “Waitlist”](#waitlist) `waitlist` · also waiting list *Can an agent join a waitlist?* Join a waitlist for a time, a table, a class or a product, and leave it. **Partial:** Only behind the customer’s own account. | Evidence | What has to be seen | | ---------- | ---------------------------------------------------------------------------------------------------- | | `declared` | A tool, skill or operation that joins a waitlist. | | `proven` | Signed reports from agent platforms a network recognises, saying that a place on the list was taken. | Not enough: * A form for people to leave their email. Through each kind of door, for example: * MCP server: `join_waitlist` * A2A agent: `join-waitlist` * OpenAPI: `POST /waitlist` * REST API: `POST /waitinglist` ### Support [Section titled “Support”](#support) `support` · also complaint, ticket, dispute *Can an agent raise a complaint or a problem with an order?* Raise a complaint or a problem with a booking or an order, and follow it. **Partial:** A problem can be raised and nothing comes back through the door. | Evidence | What has to be seen | | ---------- | ------------------------------------------------------------------------------------------------------------- | | `declared` | An agent inbox that takes messages, or a tool, skill or operation that opens a support ticket or a complaint. | | `proven` | Signed reports from agent platforms a network recognises, saying that a ticket was opened and answered. | Not enough: * A help centre of articles for people. Through each kind of door, for example: * Agent inbox: `message` * MCP server: `create_support_ticket`, `file_complaint` * A2A agent: `open-ticket` * OpenAPI: `POST /tickets` * REST API: `POST /complaints` ## Policies [Section titled “Policies”](#journey-policies) ### Policies [Section titled “Policies”](#policies) `policies` · also terms, returns policy, cancellation policy *Can an agent read the cancellation, return, deposit or no-show policy?* Read the terms that matter before and after: cancellation, returns, deposits, no-shows and delivery. **Partial:** The policy is on a page an agent can read and not as data or through a door. | Evidence | What has to be seen | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `declared` | schema.org MerchantReturnPolicy or similar data, a policy in the inbox’s terms, or a tool, skill or operation that reads a policy. | | `proven` | Signed reports from agent platforms a network recognises, saying that the policy read answered. | Not enough: * A policy only in a PDF. Through each kind of door, for example: * MCP server: `get_policies`, `get_return_policy` * A2A agent: `policies` * OpenAPI: `GET /policies` * REST API: `GET /terms` ## Changelog [Section titled “Changelog”](#changelog) * **Version 2**, amended 9 November 2026. Pay and message say what the business published itself or a network’s read tested (score rules version 4): payment handlers of the business’s own, never only a website platform’s standard ones, and a tool whose name says pay or payment, never one whose only payment word is checkout; an A2A agent only once its endpoint answered a network’s test call; and a website platform’s question-answering tool is not message. completeCheckout is no longer an example of pay (it orders); createPayment is. No capability was added, renamed or removed. * **Version 2**, amended 18 October 2026. A UCP profile also counts on a site when the catalogue it names, asked by a network’s read, is the site’s own and lists a product for sale with a price (score rules version 3, amended 18 October 2026): the parentheses that say what shows a site sells now say so. No capability was added, renamed or removed. * **Version 2**, published 18 October 2026. Each capability now has a definition, other words for it, what partial means for it, what has to be seen at each step of the evidence ladder, what is not enough, and how each kind of agent door meets it, with example names. Message, negotiate, book, order and pay name the kind of request a directory filters by (ask, quote, book, order, pay). The door kinds, what never counts as a door and how names are read are written down. Order, pay, the catalogue, availability, tracking and returns need something the business really sells when they rest on a UCP or ACP profile: a commerce platform’s profile that every site on it gets is not enough on its own. A name that reads documentation meets nothing. No capability was added, renamed or removed: every id, label and question is as in version 1. * **Version 1**, amended 13 October 2026. Tested is produced: a network’s own read of a door that saw it answer and list a capability in the 7 days before. * **Version 1**, published 7 October 2026. Twenty capabilities in five parts of a customer’s journey, each with a label and a question, the states yes, partial, no and na, and the evidence ladder declared, tested, proven. Version 1 is kept as it was last served, as amended on 13 October 2026 ([`/spec/capabilities/v1.json`](/spec/capabilities/v1.json)). Ids never change: a capability is added, never renamed or removed. What counts toward a score, and how much, is a score’s own rules, published apart ([the agentic score’s](https://surfingdog.ai/v1/score-rules)).
# The census of what AI agents can do
> How the monthly census counts what AI agents can do at the websites we checked, and the format of its open data. Method version 2.
**Method version 2**, last changed 9 November 2026. JSON Schemas: [an edition](/spec/schemas/census-edition.json), [the list of editions](/spec/schemas/census-editions.json) · an example, of method version 1 (version 2 adds the `_own` and `_platform` metrics): [`census-edition.json`](/spec/vectors/census-edition.json). The counts are open data under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/): credit “Surfing Dog census, surfingdog.ai, CC BY 4.0”. Once a month we count how many of the websites we checked an AI agent can do each thing with: send a message, book, order, pay, and the rest of [the capability vocabulary](/spec/capabilities/). The census counts. It never names a website. ## Who is counted [Section titled “Who is counted”](#who-is-counted) Every website our crawler has read and scored, except: * a site whose owner hid its result, * a site that asked not to be read, * the kinds of business the directory leaves out: adult services, gambling, weapons, tobacco, cannabis and vaping, payday loans and bail bonds. The sites come from: * **open place data**: places with a website in [Overture Maps](https://overturemaps.org) places data; * **public lists**: the most visited sites in the Chrome UX Report; * **the MCP Registry**, a list of servers made for agents; * **links** our crawler followed; * **checks** people asked for, and businesses in the directory. Each edition counts the sites from each of these on its own (`source`). Most of the sites are businesses; some are not, like governments, universities and encyclopedias, which are among the most visited sites. A list chosen for its agent doors, like the MCP Registry, has a high share by how it was chosen. This is not a random sample of the world’s websites: say so wherever you use the numbers. ## What is counted [Section titled “What is counted”](#what-is-counted) | Metric | Counts | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `checked` | websites read | | `agent_ready` | of those, the ones an agent can reach through a door: askable or above, or a business in the directory with its own inbox. A site that tells AI systems not to use its content is never counted as agent-ready, unless it runs its own inbox in the directory. | | `agent_ready_own`, `agent_ready_platform` | of the agent-ready, those an agent reaches through a door the business published itself (its own inbox included), and those it reaches only through a door its website platform gives every site it hosts, like the shopping profiles and servers Shopify, Wix, Salla, BigCommerce and Jumpseller serve for every store, and the MCP server WebForger serves for every site. Over the sites scored under the rules in force the two add up to `agent_ready` (an edition may be taken with at most 0.1% of the agent-ready scored under older rules: those are counted in `agent_ready` and on neither side). | | `can:` | of the agent-ready, those where an agent can do it: its state is `yes`, through a door the site published, and not on experimental evidence alone | | `can_own:`, `can_platform:` | of those, the sites agent-ready through a door of their own where that door or the site’s own pages let an agent do it, and the rest, where only a platform’s door lets an agent in | | `door:` | of the agent-ready, those with a live door of that type: `inbox`, `mcp`, `a2a`, `openapi`, `ucp`, `acp`, `webmcp`. An A2A agent counts once its endpoint answered our test call. Two servers a website platform gives every site it hosts are no door: Shopify’s storefront MCP server and Wix’s MCP server (`/_api/mcp`). A platform’s other doors, like Salla’s, Jumpseller’s and WebForger’s MCP servers, the UCP server at `/api/ucp/mcp` and the shopping profiles platforms serve for their stores, count as `mcp` and `ucp` doors, on the platform’s side (`door_platform:`). | | `door_own:`, `door_platform:` | of those, the sites with such a door of the business’s own, and those with only their platform’s | | `level:` | how many reached each readiness level: `none`, `listed` and `readable` among those checked, `askable` and above among the agent-ready | | `level_own:`, `level_platform:` | from `askable` up: the sites whose own doors take them to that level, and those that get there only through their platform’s | | `no_ai` | of those checked, the sites whose doors reach askable or above and that ask AI systems to stay away | | `places` | for a city: how many places its list holds, read or not, so you can see how much of the city the counts cover | | `pay_read` | for the whole, a country and a city: of those checked, the sites whose ways to pay we read | | `pay:` | of those, the sites that show a way to pay at all: a token of [the payments vocabulary](https://github.com/surfingdogai/inbox/blob/main/packages/spec/vocab/payments.json), such as `card`, `apple_pay`, `paypal` or `x402` | | `pay_declared:` | of those, the sites that declared it themselves; `pay:` minus `pay_declared:` is those where it was only seen | The levels and `no_ai` add up to `checked`. Each `_own` and `_platform` pair adds up to its total over the sites scored under the rules in force (above). What an agent can do through a door follows the score rules: it can pay only through a way to pay the business declared itself or our read tested, never through a website platform’s standard payment handlers alone or a checkout tool, and a platform’s tool that answers questions from the store’s own pages (Shopify’s `ask_business`) is not a way to send the business a message. A way to pay is declared when the business says so itself: its own structured data (schema.org `paymentAccepted`), its inbox’s settings, its registration, or its own shop profile (UCP payment handlers, ACP delegated payment, an agent payment protocol its door declares in its structure). It is only seen when it does not, but a payment provider’s own code, button, payment icon or hosted checkout link is on its pages, its Apple Pay domain file is on its site, or its shop platform’s standard profile lists it (the handlers a platform gives nearly every store). A page’s words are never counted, and seen never means an agent paid there. A card brand counts as a card: `pay:card` holds every site that shows one, and each brand has its own rows. The census page leaves out `ucp_handlers`, the protocol itself, and shows the brands only in the data. A capability’s state comes from the score rules in force when the edition is taken; the edition names their version, the day they were last revised (`score_rules.revised`: a version amended in place counts differently), their content’s hash and how many of the sites checked were scored under them. An edition is never taken while the scores are moving to new rules. A state is declared (the site’s own pages or door say so) or tested (our own read saw the door list it). Neither means an agent carried it out. ## Breakdowns [Section titled “Breakdowns”](#breakdowns) * **Country**: the country the site’s address data names; else its country domain (`.fr` is France, `.uk` the United Kingdom; `.io`, `.co`, `.ai` and other domains sold as names are no country); else the region of its page’s language; else, for a site whose own pages say none of these, the country of its verified place in open place data from [Overture Maps](https://overturemaps.org): a place our own check matched to the site (the place’s name on the site’s own pages, with its town, postcode or phone). Of the agent-ready sites that had both on 8 November 2026, the two agreed for 99.6% of those with a verified place. A place our check has not verified, or found under another name, at another site, parked or taken over, gives no country. A site with none is counted under `none`. * **Kind of business**: the directory’s category group. Software, API and AI companies are a kind of their own. A site whose kind we could not tell is counted under `none`. Online shops are partly recognised by their agent doors (a shopping protocol’s profile, a shop platform’s door), so their share is high by construction. * **Platform**: the website builder or shop system we read on the site’s own pages, `none` when we saw none. * **Source**: where the site came from, as above: `places` (open place data), `seed` (the public lists), `registry`, `discovered`, `checked`. * **City**: the ten cities we have sampled so far. Each city’s list is a random sample of up to 25,000 places with a website in seven kinds (restaurants, cafés and bars; hotels; hair and beauty; dentists; clinics; shops; gyms) from [Overture Maps](https://overturemaps.org) places data; the edition names the lists (`method.places_sources`). A city’s `places` is its list’s size, not how many businesses it has. A city is partial until we have read 80% of its list (`method.city_read`). * **Minimum sample**: a country, kind of business, platform, source or city is published only when we checked at least 100 of its sites. Each edition says how many were left out. Within a published key, small counts are published as they are: each counts public features of websites, and no row names one. ## Editions [Section titled “Editions”](#editions) * An edition records one month and is named for it: `2026-10`. * It is taken as a draft once the month has ended, on the next month’s first day after 00:30 UTC. A person reads it, then publishes that draft, named by when it was taken (`as_of`). A draft taken before its month ended is published only with a note saying why (`publish_note`). * Once published, its counts never change. If a mistake is found, the edition is marked withdrawn, stays readable, and says why. * A change from the previous edition is a change in share, in percentage points of those checked (or of the agent-ready, for doors): the sites checked change from month to month, so a count can grow while its share does not. ## The data [Section titled “The data”](#the-data) * `GET https://surfingdog.ai/v1/census` lists the editions. * `GET https://surfingdog.ai/v1/census/{edition}` is one edition as JSON; `https://surfingdog.ai/census/{edition}.csv` the same rows as CSV, with the header `edition,scope,key,metric,value`. The MCP tool `get_census` answers the same, cut to one scope or key. * Rows are long: one count per scope, key and metric. Every published key has a row for every metric of its scope, zero included (the payment metrics are counted for the whole, countries and cities). A reader skips a scope or a metric it does not know: both are added to, never renamed. * The counts are open data under [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/) (CC BY 4.0): use them for anything, crediting “Surfing Dog census, surfingdog.ai, CC BY 4.0”. City counts, and the countries of sites whose own pages name none, rest on Overture Maps places data: credit “© Overture Maps Foundation” with them (`licence.credits`). The directory’s own data stays under the Open Database License 1.0. ## Changelog [Section titled “Changelog”](#changelog) * **9 November 2026**: method version 2, with the score rules version 4. Every count of the agent-ready, of what an agent can do, of its doors and of its levels from askable up is counted again by how the sites get there: through a door of the business’s own (`_own`), or only through one its website platform gives every site it hosts (`_platform`). Paying counts only for a way to pay the business declared itself or our read tested; an A2A agent only once its endpoint answered our test call; Wix’s MCP server is not an MCP door, as Shopify’s storefront server never was; and Shopify’s `ask_business` is not a message. A site whose own pages name no country is counted under the country of its verified place in Overture Maps places data, and the method now names open place data as a source of its own (`places`), as the counts already kept it. An edition of method version 1 is not compared with one of version 2. * **18 October 2026**: ways to pay, for the whole, each country and each city: `pay_read`, `pay:` and `pay_declared:`, and the edition names the tokens it counts (`payments`). The schema’s `metric` is from now on any lower-case name with at most one key after a colon, so a reader that validates editions does not refuse one with a metric added later (0.6’s named each metric, and refuses these: move to 0.7). * **18 October 2026**: websites, not businesses; where the sites came from (`source`); the levels from askable up among the agent-ready, and `no_ai`; the score rules by revision, hash and share; changes in percentage points; cities partial below 80% of their list; a draft published by when it was taken; the counts under CC BY 4.0, credited “Surfing Dog census, surfingdog.ai, CC BY 4.0” (the founder’s decision of this day; the ODbL before). Nothing was published before. * **15 October 2026**: method version 1.
# Agent inbox manifest
> The format of /.well-known/agent-inbox.json, surfingdog-inbox/0: what a business's agent inbox takes and where its doors are. Versioned, with a JSON Schema and test vectors.
**Format `surfingdog-inbox/0`**, last changed 15 October 2026. [JSON Schema](/spec/schemas/manifest.json) · vectors: [`doors-elsewhere.json`](/spec/vectors/doors-elsewhere.json), [`profile.json`](/spec/vectors/profile.json) · [check one](/spec/check/) · MIT licence. An agent inbox publishes one JSON document that says what kinds of request it takes and where each of its doors is. An agent reads it to choose a door without opening every card. How Surfing Dog Inbox builds its own is on [Manifest](/docs/manifest/). ## Where it is [Section titled “Where it is”](#where-it-is) * At `https:///.well-known/agent-inbox.json`, over https, as `application/json`. * A business may also point to it from its main domain with a DNS record: `_agent-inbox.` TXT `v=sdi1; manifest=`. * A reader takes the inbox’s receipt keys from this document at the inbox’s own host, never from anything the inbox sends. ## Fields [Section titled “Fields”](#fields) | Field | Must it be there | What it holds | Since | | ------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `spec` | yes | `"surfingdog-inbox/0"` | 21 Sep 2026 | | `instance` | yes | the inbox’s https origin: scheme and host, no path, no trailing slash | 21 Sep 2026 | | `item_types` | yes | the kinds of request it takes, each once: `message`, `quote_request`, `booking`, `order`, `refund`, `stay`; `[]` for a business that takes none here and only lists its products (its orders are taken at its own checkout) | 21 Sep 2026; `[]` for a catalogue only, catalogue version 2 | | `protocols` | yes | protocol name → the door’s https URL: `mcp`, `openapi`, `rest`, `a2a`, `ucp` and others | 21 Sep 2026 | | `agent_policy.tiers` | yes | at least one of `anonymous`, `signed_agent`, `verified_principal`, `reputed_principal` | 21 Sep 2026 | | `agent_policy.signatures` | no | the request signatures it checks, at most 8: `sdi-agent/1`, `web-bot-auth` | 23 Sep 2026 | | `agent_policy.passes` | no | whether it presents a person’s passes to their networks | 23 Sep 2026 | | `agent_policy.networks` | no | the networks whose people it recognises, at most 8 https origins | 23 Sep 2026 | | `agent_policy.guide` | no | a URL: how an agent says who it is, and who its person is, here | 23 Sep 2026 | | `receipt_keys` | yes | a JWKS of the Ed25519 public keys it signs receipts with (below); `{"keys": []}` when it signs none | 21 Sep 2026 | | `review_services` | yes | the networks it sends receipts to, as https origins; `[]` for none | 21 Sep 2026 | | `profile` | no | public facts the business chose to share: name, description, categories, languages, address, coordinates, website, hours, services, place category, attributes, price band, currencies, and how it is paid (`payments`: tokens of the [payments vocabulary](https://github.com/surfingdogai/inbox/blob/main/packages/spec/vocab/payments.json), such as `card`, `apple_pay` or `paypal`; `pay_to`: where to send one paid to an address, such as a Stripe Pay handle), what a place that serves food cooks (`cuisines`: at most five slugs of the [cuisines vocabulary](https://github.com/surfingdogai/inbox/blob/main/packages/spec/vocab/cuisines.json), such as `ramen` or `portuguese`), how it takes tables (`reservations`), and where each field came from (`sources`) | 21 Sep 2026; hours and services 26 Sep; place category, attributes, price band and currencies 6 Oct; a day that closes at `24:00` 15 Oct; payments 18 Oct; `pay_to` 31 Oct; cuisines, reservations, sources and a description of 600 characters 1 Nov | | `directory` | no | `{"listed": false}` keeps the business out of networks’ directories | 26 Sep 2026 | | `claims` | no | network host → claim token: proof that a listing on that network is the business’s own | 6 Oct 2026 | | `catalogue` | no | where a network reads the business’s products, while they are listed: `feed` and `items`, https on the business’s own domain; `versions`, the catalogue versions the feed serves, each once (absent: `[1]`; `[2]` alone answers a read that names no version `422 version_required`); `policies`, the signed document of its shipping profiles and return policies ([protocol §4.18](https://github.com/surfingdogai/inbox/blob/main/docs/protocol/network.md)) | spec 0.9; `versions` and `policies` catalogue version 2 | | `doors_elsewhere` | no | at most 5 `{"item_type", "url", "platform"?}`: kinds of request it takes somewhere else, never one in `item_types`, each at an https address on a named host; `platform`, a token of the [door platforms](https://github.com/surfingdogai/inbox/blob/main/packages/spec/vocab/door-platforms.json) (`fresha`, `cloudbeds`, `flipdish`…), names the platform the page is on | 12 Oct 2026; `platform` 20 Oct | The JSON Schema has every bound: lengths, counts and formats. ## Receipt keys [Section titled “Receipt keys”](#receipt-keys) Each key in `receipt_keys.keys` is an Ed25519 public JWK: * `kty` is `OKP`, `crv` is `Ed25519`, and `x` is the public key, 43 base64url characters. * `kid` is the key’s RFC 7638 thumbprint. A receipt names its key by it. * It never carries `d`. A key whose private half was published is no longer secret: replace it. * A retired key stays in the list, so receipts it signed still verify. ## Reading one [Section titled “Reading one”](#reading-one) * Ignore any field you do not know. * Read `profile` and `doors_elsewhere` field by field and entry by entry: drop a bad one, keep the rest. The vectors show exactly what a reader keeps. * Never ask a business to list a door it cannot fulfil, and never treat a door listed here as proof it works: that takes reading it. ## How it changes [Section titled “How it changes”](#how-it-changes) New fields are added, and only as optional ones, so a reader of an older manifest keeps working. A field may also come to take a value it did not take before, as a day’s hours that close at `24:00` did on 15 October 2026: a reader written before it refuses that one value, as it refuses any value it does not know, and reads the rest. A change that would break a reader comes with a new `spec` value, `surfingdog-inbox/1`. ## Changelog [Section titled “Changelog”](#changelog) * **0.15** (catalogue version 2): in `catalogue`, `versions` (the catalogue versions the feed serves; a reader names the one it reads, `version=2`) and `policies` (the signed shipping profiles and return policies its offers name); `item_types` may be `[]` for a business that only lists its products, and a network then makes no read of an inbox door it does not have. A reader before it ignores both and reads the feed as version 1, which a feed that lists `[2]` alone refuses. * **1 November 2026** (spec 0.14): in `profile`, `cuisines` (at most five slugs of `vocab/cuisines.json`; a reader keeps the ones it knows and reads a label or synonym as its slug), `reservations` (`required`, `recommended`, `accepted` or `walk_in_only`) and `sources` (for each field, the business’s own word or its website, with the page and when); `description` holds 600 characters, 500 before. A reader before 0.14 ignores the new fields; a network before 0.14 cuts a longer description to 500 characters, and a reader that checks it against the 0.13 schema drops it. * **31 October 2026** (spec 0.13): in `profile`, `pay_to`: where a payer sends a way to pay that goes to an address of the business’s own, at most 8 entries `{method, to, currencies}`. Today only `stripe_pay`: a Stripe profile handle (`@acme`) or profile id (`profile_…`), in USD, GBP or USDC. A reader keeps an entry only for a method `payments` names too, and drops anything else alone. * **20 October 2026** (`@surfingdog/spec` 0.10): in `doors_elsewhere`, `platform`: the platform a door’s page is on, a token of `vocab/door-platforms.json`, so an agent says “Book at Fresha”. A reader keeps it only when it knows the token, the platform takes that kind of request and the page is the business’s own there; otherwise it drops the platform alone and keeps the door. A reader before 0.10 ignores it. * **18 October 2026**: in `profile`, `payments`: how customers and their agents can pay, at most 64 tokens of `vocab/payments.json`. A reader keeps the tokens it knows and drops the rest alone; a card brand says `card` too. * **15 October 2026**: in `profile.hours`, a day may close at `24:00`: open until midnight, or all the time from `00:00`. A reader written before this day (`@surfingdog/spec` before 0.5) refuses `24:00` in that one day’s hours, and keeps the rest of the profile. * **12 October 2026**: `doors_elsewhere`, the kinds of request a business takes somewhere else, and where. * **6 October 2026**: `claims`; in `profile`, `place_category`, `attributes`, `price_band` and `currencies`. * **26 September 2026**: `directory`; in `profile`, `hours` and `services`. * **23 September 2026**: in `agent_policy`, `signatures`, `passes`, `networks` and `guide`. * **21 September 2026**: the format: `spec`, `instance`, `profile`, `item_types`, `protocols`, `agent_policy.tiers`, `receipt_keys` and `review_services`.
# Receipts
> The receipt format: a signed record of a promise a business made and how it ended, and the customer's agent's counter-signature. Claims version 2, with JSON Schemas and test vectors.
**Claims version 2**, last changed 27 September 2026. JSON Schemas: [claims v1](/spec/schemas/receipt-v1.json), [claims v2](/spec/schemas/receipt-v2.json), [acknowledgement](/spec/schemas/receipt-ack-v1.json), [acknowledgement with `pas`](/spec/schemas/receipt-ack-v2.json) · vectors: [`receipts.json`](/spec/vectors/receipts.json), [`receipts-v2.json`](/spec/vectors/receipts-v2.json), [`receipts-v6.json`](/spec/vectors/receipts-v6.json) · [check one](/spec/check/) · MIT licence. A receipt is a compact JWS an inbox signs when it makes a promise (a booking confirmed, an order accepted or paid, a refund owed), when both sides agree to change it, and when it ends. The customer’s agent counter-signs it, so both sides hold the same proof. How Surfing Dog Inbox issues them is on [Receipts](/docs/receipts/); why, in [ADR-016](https://github.com/surfingdogai/inbox/blob/main/docs/adr/016-receipts.md). ## The signature [Section titled “The signature”](#the-signature) * A compact JWS signed with Ed25519. The header is `{"alg":"EdDSA","typ":"sdi-receipt+jws","kid":""}`. * `kid` is the RFC 7638 thumbprint of the signing key. The key is in the issuer’s [manifest](/spec/manifest/) under `receipt_keys`, and at `/.well-known/jwks.json`. * A verifier chooses the algorithm itself. Anything but EdDSA is refused, whatever the header says. ## Claims [Section titled “Claims”](#claims) | Claim | Version | Must it be there | What it holds | | ------- | --------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `iss` | 1, 2 | yes | the inbox’s origin; https in v2 | | `sub` | 1, 2 | yes | the customer, as `base64url(HMAC-SHA-256(pepper, identity))`, 16 to 64 characters; never an address, never a bare hash of one | | `itm` | 1, 2 | yes | the item’s id at the inbox, 1 to 64 characters | | `typ` | 1, 2 | yes | v1: `message`, `quote_request`, `booking`, `order` or `refund`; v2: `booking`, `order`, and `refund` (added 27 September 2026) | | `knd` | 1, 2 | yes | v1: `confirmed` or `paid`; v2 adds `accepted`, `outcome`, and `amended` (added 27 September 2026) | | `iat` | 1, 2 | yes | Unix seconds: when the event happened | | `nonce` | 1, 2 | yes | 32 lowercase hex characters; a network counts `(iss, nonce)` once | | `amt` | 1, 2 | no | `{"value", "currency"}`, value in minor units (4500 is 45.00); never below 0 in v2 | | `pay` | 1, 2 | no | how it was paid, at most 40 characters | | `ver` | 2 | in v2 | `2`; absent (or `1`) is v1 | | `due` | 2 | yes | Unix seconds, at most 2^37: a booking’s start, an order’s delivery, a refund’s pay-by date | | `end` | 2 | no | a booking’s end, never before `due`, on a booking only | | `out` | 2 | with an outcome | how the promise ended: one of the outcomes below, of the item’s own type | | `ref` | 2 | with an outcome or an amendment | the `nonce` of the item’s earliest promise | | `trm` | 2 (27 Sep 2026) | with an amendment | the agreed terms’ fingerprint, 43 base64url characters; on a promise or an amendment, never on an outcome | | `acc` | 2 (27 Sep 2026) | with an amendment | who accepted the change: `customer` or `business`; on an amendment only | | `aut` | 2 | no | `1` when the system did it, not a person | | `per` | 2 | no | at most 8 `{"n": network host, "p": presentation id}` | The outcomes an inbox signs: `booking.completed`, `booking.no_show_customer`, `booking.cancelled_by_business`, `booking.cancelled_by_customer`, `booking.cancelled_late_by_customer`, `order.fulfilled`, `order.not_fulfilled`, `order.cancelled_by_customer`, `order.payment_failed`, `order.charged_back`, `order.lapsed`, and, added 27 September 2026, `refund.honoured`, `refund.late` and `refund.cancelled_by_customer`. A receipt never carries a name, an address or what was ordered. ## Checking one [Section titled “Checking one”](#checking-one) In this order, stopping at the first that fails, with the code a network answers: 1. Three base64url parts, the first two JSON objects. Else `malformed`. 2. `alg` is `EdDSA`. Else `bad_alg`. 3. `typ` is `sdi-receipt+jws`. Else `bad_typ`. 4. `kid` names a key the issuer publishes. Else `unknown_key`. 5. The signature verifies with that key. Else `bad_signature`. 6. The claims hold to the table above. Else `bad_payload`. 7. `iat` is at most 300 seconds ahead of the clock. Else `not_yet`. A reader expecting a given inbox also refuses another issuer (`wrong_issuer`). ## The acknowledgement [Section titled “The acknowledgement”](#the-acknowledgement) The customer’s agent counter-signs a receipt with its own Ed25519 key:
```plaintext
header {"alg":"EdDSA","typ":"sdi-receipt-ack+jws","jwk":{"kty":"OKP","crv":"Ed25519","x":"…"}}
payload {"rcp":"","sha":"","iat":,"pas":""}
```
* `rcp` is the receipt’s id at the inbox. `sha` is the hash of the receipt’s JWS exactly as received, so anyone holding both can check they belong together. * `pas` is optional: a pass reference, `sdpass1__`, never the pass itself. * The inbox takes one at most an hour old (`expired`) and at most 300 seconds ahead (`not_yet`). The first one it takes is the one both sides hold. ## Sending them to a network [Section titled “Sending them to a network”](#sending-them-to-a-network) `POST /v1/receipts` with `{"receipt": "", "ack": ""}` (`ack` when there is one). The network answers `{"ok": true, "state": "issued" | "acknowledged", "duplicate": }`. It trusts nothing in the body: it finds the issuer by `iss`, takes the keys from the manifest it fetched itself, and verifies both. ## How it changes [Section titled “How it changes”](#how-it-changes) Claim names never change: a receipt outlives the software that signed it. New claims and values are added. A reader ignores a claim it does not know and refuses a value it does not know. The additions of 27 September 2026 show how that works: a reader written before that day drops `trm` and `acc`, and refuses refunds and amendments (`receipts-v6.json` shows exactly what it keeps). A change that would break a reader comes with a new `ver`. ## Changelog [Section titled “Changelog”](#changelog) * **27 September 2026**: the kind `amended`, the type `refund` with its three outcomes, `trm` and `acc`. * **23 September 2026**: claims version 2: the kinds `accepted` and `outcome`, `due`, `end`, `out`, `ref`, `aut` and `per`; an acknowledgement may name a pass (`pas`). * **22 September 2026**: receipts: `iss`, `sub`, `itm`, `typ`, `knd`, `iat`, `nonce`, `amt` and `pay`, and an acknowledgement bound to the receipt’s hash (`sha`).