Skip to content

API

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). 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. Every instance serves its own at /openapi.json.

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 says whether a customer who paid may withdraw (standard) or the exception the law allows (dated_leisure, urgent_repair, …). Paged.
list_products GET /v1/products Orderable products with prices in minor units, and withdrawal (standard, or perishable, personalised, sealed_hygiene, …), 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. 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).
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.
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. 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. See 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.
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.

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_<network>_<id>) 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.

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, 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 (the business changed a price meanwhile) the answer is the same 409 with the current summary and details.changed: true. 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.

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.

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.

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 a business that sells only to businesses (commerce.customers) gives no such right.

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; lapsed; business_customer; not_paid; started). 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”). 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, 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). 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; after it, 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.

Authenticate with a Bearer token: an owner key, an integration key (see 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 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).
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.<origin>.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); [] 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; product feeds (list_feeds, add_feed, import_feed_now, remove_feed) in Feeds. The OpenAPI document lists every owner operation with its query parameters, its response schema and its security.

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:

{ "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 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 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.

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.

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, 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/<code>), 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. Your returns settings are the default return policy, standard, until you write its terms. 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 in the EU, the EEA and 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 legal right to withdraw still holds wherever the law gives one. A return for a refund stays open as long as the policy you publish for each product says, never less than your returns settings. 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.

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).
  • /.well-known/agent-inbox.json: the 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.

@surfingdog/sdk (MIT, no dependencies) is on npm. For a business’s systems it carries the webhook verifier 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.