SchCP — Scheduling Context Protocol

Draft specification v0.4 — October 2026. v0.4 makes SchCP a protocol for arranging time and nothing else: it never moves money (§8). A price, where there is one, is a term the host discloses and the human approves; a service may have no price at all (§5.2). A booking may have a start and no agreed end (§4.2), and may happen somewhere other than at the host's (§5.2). A host may be a person whose own agent keeps their calendar, open only to the people on their contact list (§7.7, §7.8). Several people meet under one contract, signed by the organizer, that every member accepts by the same hash; nothing is confirmed anywhere until all of them have (§5.12). A confirmed meeting keeps its time until every member agrees to a change, and a business can be a member, its signed offer its yes (§5.12). A person's yes comes from them, or from a rule they signed for their agent, and every booking says which (§7.9). Each host states how long it keeps booking records, and every receipt says how the person agreed and under which mandate (§2.1, §5.3). A host waiting to answer may counter with another place or time (§5.11), and a host that approves each booking approves a guest's move too (§5.7). SchCP also keeps no account of cancellations and no-shows: offers carry no fee terms, cancelling is always free and records nothing, and a host's own late-cancellation or no-show policy is its own business (§5.6, §7.6). A host may still mark a past booking completed or no_show, because a kept appointment is what earns a guest standing (§6, §7.2). The merchant/customer vocabulary of v0.3 becomes host/guest throughout. v0.3 made every agreement a hashed, host-signed offer (§5.3, §5.7), stated an all-in total (§5.2), recorded late cancellations and no-shows instead of charging them, with a 60-second undo and a cancel quote (all three removed in v0.4), and added a waitlist (§5.9), bookings that other people confirm with their own key apps (§5.10), a standard refusal shape (§9.2), shared capacity and parties (§4.1), release schedules, surcharges, eligibility, service areas and a walk-in reserve (§5.2), host extensions and displaced bookings (§5.8), mandates for several hosts with a shared booking limit (§3.3), operator-wide limits (§7.2, §7.3), brands (§2.4) and calendar cold start (§10). v0.2 added closed mandates, attestations and reputation decay to v0.1. Companion to the whitepaper "Arranging People's Time". The key words MUST, SHOULD and MAY are used as in RFC 2119.

Versions. This document, the JSON Schemas ($id https://schcp.org/schemas/0.4/schcp.schema.json) and the reference implementation's package (0.4.x) share a minor version: an implementation of spec v0.N validates against schemas 0.N. Every version so far is an unreleased draft: none is a Final Specification in the sense of IPR_POLICY.md, and drafts make no compatibility promise to one another.

SchCP arranges time between people: a business and its guest, or two people. It lets a consumer agent (acting for a human) discover a host whose time can be booked (a chair, a bay, a table, a practitioner, or a person's own free afternoon), query availability, place a short-lived hold, and turn it into a booking under a mandate: a signed, machine-readable statement of what the human authorized. Every commitment rests on that signed consent; no money moves through the protocol. It is meant as a first step toward agents coordinating people's time on their behalf. It standardizes payload semantics and the state machine, not the transport; this document specifies the HTTPS+JSON binding.

1. Roles

Role Description
Principal The human. Holds a signing key (in a key app, OS keychain or identity provider) used only to issue mandates.
Key app The principal's own app that holds their signing key. It shows them the terms, and signs the mandates and approvals they agree to (§3.3, §5.3). It holds no money: SchCP never moves any (§8).
Consumer agent Software acting for the principal. Holds its own key; signs every request.
Host Whoever's time is booked: a business (a shop, a clinic) or a person whose own agent keeps their calendar (§7.7). Publishes the discovery document.
Guest The principal on whose behalf a booking is made at a host.
Host endpoint The server that speaks SchCP for the host. May be operated by the host, by its booking platform or calendar service, or by a connector.
Operator Whoever runs the host endpoint, if not the host (e.g. a booking platform, or a personal-calendar service).

2. Discovery

2.1 The discovery document

A host publishes https://{host-domain}/.well-known/scheduling.json. A host without its own domain is published by its operator: https://{operator-domain}/.well-known/scheduling.json lists the hosts it serves (§2.3).

{
  "schcp": "0.4",
  "host": {
    "id": "maple-street-auto",
    "name": "Maple Street Auto",
    "kind": "business",
    "timezone": "America/Toronto",
    "url": "https://maplestreetauto.example",
    "address": { "locality": "Toronto", "region": "ON", "country": "CA" }
  },
  "endpoint": "https://maplestreetauto.example/schcp/v0",
  "operator": null,
  "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "…", "kid": "msa-2026-10", "use": "sig" } ],
  "services": [
    {
      "id": "oil-change",
      "name": "Synthetic oil change",
      "duration": "PT45M",
      "buffer_after": "PT15M",
      "price": { "amount": "79.00", "currency": "CAD" },
      "requires": [ { "resource_type": "bay" }, { "resource_type": "technician" } ],
      "booking_window": { "min_notice": "PT2H", "max_advance": "P60D" }
    }
  ],
  "policies": {
    "holds": { "max_ttl": "PT10M", "max_active_per_agent": 3, "requires_mandate": true,
               "max_active_unproven": 8, "max_active_per_attester": 8 },
    "confirmation": "instant",
    "closed_mandate": "never",
    "retention": "P365D"
  },
  "capabilities": ["availability", "holds", "bookings", "cancel", "change_offers",
                   "webhooks", "proposals", "closed_mandates", "attestations",
                   "all_in_totals"],
  "auth": {
    "request_signing": "rfc9421-ed25519",
    "mandates": ["schcp-mandate+jwt", "dc+sd-jwt"]
  },
  "trust": {
    "attesters": [ { "id": "phonecheck.example", "name": "PhoneCheck",
                     "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "…" } ] } ]
  }
}

Durations are ISO 8601, in weeks, days, hours, minutes and seconds: no years or months, whose length varies. The grammar is P[nW][nD][T[nH][nM][n[.n]S]] (the Duration schema): at least one part, a T only before a time part, digits only, a decimal point (never a comma) only in seconds, no sign, upper case. Weeks and days may be combined (P2W3D), which ISO 8601's basic format does not allow. A week is 7 days and a day 86 400 s; equivalent forms (P1W, P7D, PT168H) are the same length, and a reader accepts any of them. An implementation that writes a length it computed uses days, hours, minutes and seconds only (never weeks), leaves out zero parts, and writes zero as PT0S (604 800 s is P7D, 90 061 s is P1DT1H1M1S). A retention (below) is in weeks or days only (the Retention schema).

Money, where a host states any, is a decimal string plus an ISO 4217 currency code; never a float.

Strings. Schema patterns are ECMA-262 regular expressions, as JSON Schema requires: $ matches only at the very end, so "PT1H\n" is not a duration. A validator whose regular expressions let $ match before a final newline (Python's re, for instance) MUST anchor them at the end of the string instead, and \d matches the ASCII digits 0–9 only. Numbers follow JSON Schema too: 1792000000.0 is an integer.

Formats. Validators MUST assert the schema's format keywords, which JSON Schema 2020-12 otherwise treats as annotations: date-time is an RFC 3339 date-time with a time-zone offset (Z or ±hh:mm), and uri an RFC 3986 URI. An unparseable date is a schema failure, never a value to compare.

host.kind is "business" (the default) or "person" (§7.7). A service's price is optional: a service without one has nothing about money in its terms (§5.2), and SchCP never takes payment for one that has one (§8). duration is an ISO 8601 duration or "open" (§4.2).

policies.confirmation is "instant" (a confirm request books synchronously) or "host_approval" (a confirm request returns pending_host and the host approves or declines later, as in the calendar fallback of §10, or as a person's agent does after asking them). Every booking a host said yes to says who answered: the host's person or a rule they signed (answer, §7.9).

policies.closed_mandate says when confirm needs the principal's signature over the exact offer (§5.3): "never" (default) or "always". Policies carry no cancellation, change or no-show terms: those are the host's own business, outside SchCP (§7.6). trust.attesters lists the attesters whose attestations the host honours (§7.5). policies.access is "public" (default) or "contacts" (§7.8).

keys lists the host's public signing keys, each with a kid unique in the document. Everything the host signs (offers, receipts, §5.2, §5.3) carries in its JWS header the kid of the key that signed it. A verifier uses the discovery key with that kid, and refuses the JWS when no key has it; it MUST NOT accept a key from anywhere else.

policies.retention (required) says how long the host keeps a booking's records after the booking ends or is cancelled: a duration in weeks or days, "P0D" for not past its end. SchCP sets no number; laws and trades differ, so each host states its own. Every offer repeats it (§5.2), so the person sees it before saying yes, and every receipt states it (§5.3). It is the host's own promise: SchCP neither sets it nor checks it.

The capabilities the reference endpoint advertises are those above, plus waitlist (§5.9), participants (§5.10) and open_end (§4.2) when it offers them, and meetings (§5.12) when the calendar takes part in meetings of several people: every person's calendar does, and a business (a café that holds a table for one) opts in. An organizer's agent reads it before holding anything; a calendar without it refuses a meeting contract (meeting_invalid).

Services and policies may also carry the fields below. Each is published so an agent can check it before asking, and each refusal it causes names it in basis (§9.2).

Field Meaning §
service.party_size: {min, max}, service.price_per: "booking"|"person" Bookings for several people §4.1
service.booking_window.opens: {days_ahead, at} A release schedule: a whole day opens at one local time §5.2
service.surcharges[] Same-day, weekend or after-hours amounts, in the total §5.2
service.eligibility: {guest: "new"|"returning"} Who may book the service §5.2
service.location: "host"|"guest"|"elsewhere", policies.service_area Where it happens: at the host's, at the guest's address, or at a place the guest names; and the area the host covers §5.2
service.duration: "open", service.set_aside A start and no agreed end, and the time set aside for it §4.2
policies.walk_in_reserve: {resource_type, keep, days?} Resources agents together may not take §7.2
policies.access: "public"|"contacts" Whether only the host's contacts may see and ask for its time §7.8
policies.data: {free_text_notes, intake_url?} Whether free-text notes from agents are accepted §5.3
brand The brand this location belongs to, and its siblings §2.4

Discovery and operator documents are not signed, and not closed: an agent ignores members it does not know. Every term that binds a booking is repeated in the signed offer (§5.2), which is closed.

2.2 Endpoint authorization (anti-spoofing)

An agent MUST NOT trust an endpoint just because a page or a directory links to it. The endpoint is authorized when one of the following holds:

  1. It is on the host's own domain, or the same origin as the discovery document fetched from the host's domain. "The host's own domain" is the exact hostname the discovery document was fetched from; a subdomain (api.maplestreetauto.example) or a parent domain is a different host, and authorized only by rule 2.
  2. The host's own discovery document names the operator ("operator": { "domain": "booksy.example" }) and the operator's document lists the host (§2.3). This is the adagents.json move: both sides have to claim the relationship.
  3. The host has no domain, and the agent accepts the operator as authoritative for its own listings (a weaker, operator-trust mode that agents SHOULD tell the principal about).

2.3 Operator documents

{
  "schcp": "0.4",
  "operator": { "name": "Example Booking Platform", "domain": "platform.example" },
  "hosts": [
    { "id": "fades-on-queen", "discovery": "https://platform.example/m/fades-on-queen/scheduling.json",
      "host_domain": null }
  ]
}

A personal-calendar service is an operator in the same sense: it lists the people whose calendars it keeps, each a host of kind "person" with host_domain: null (§7.7). Listing a person makes their discovery document fetchable; it does not make their time visible (§7.8).

2.4 Brands

A location of a chain MAY name its brand and sibling locations, so an agent can offer "the other location has 2 p.m.":

"brand": { "id": "maple-street-group", "name": "Maple Street Auto",
  "locations": [ { "host_id": "maple-street-auto", "name": "Queen St",
                   "discovery": "https://maplestreetauto.example/.well-known/scheduling.json" }, … ] }

A brand claim proves nothing alone: anyone can name a famous brand. An agent MUST treat a sibling as part of the brand only if the sibling's own discovery document, fetched and authorized as in §2.2, names the same brand.id and lists this location back. The reference SDK's verifyBrand returns each sibling with verified set accordingly. Searching across a brand's locations is out of scope (§11); the protocol only makes the relationship checkable.

3. Transport and authentication

3.1 Binding

JSON over HTTPS. Requests carry Content-Type: application/json. Every response from a host endpoint, and every response serving a discovery or operator document, carries SchCP-Version: 0.4; the documents themselves also carry "schcp": "0.4".

Parsing signed JSON. A JWS header or payload, an SchCP-Agent-Key value or a request body with a duplicate member name is invalid, so two parsers can never read different claims from the same bytes. Signed claims and request bodies are UTF-8.

3.2 Agent identity: signed requests

Every request MUST be signed with the agent's Ed25519 key using a profile of HTTP Message Signatures (RFC 9421):

Agent identity is a key, not a name, and keys are free to mint. So are self-certifying principal keys (§3.3). Per-identity limits alone therefore cannot stop one operator from minting many identities. §7.2 adds a limit across identities.

3.3 Mandates (delegated authority)

A mandate is a JWS (compact serialization, alg: EdDSA, typ: schcp-mandate+jwt) signed by the principal's key.

{
  "iss": "urn:schcp:principal:jwk-thumbprint:Qh8…",
  "sub": "principal-pseudonym-7f3a",
  "aud": "maple-street-auto",
  "iat": 1792000000,
  "exp": 1792086400,
  "jti": "m-01J9Z…",
  "cnf": { "jkt": "agent-key-thumbprint" },
  "principal_jwk": { "kty": "OKP", "crv": "Ed25519", "x": "…" },
  "scope": ["hold", "book", "cancel", "reschedule"],
  "constraints": {
    "services": ["oil-change"],
    "window": { "not_before": "2026-10-15T12:00:00-04:00", "not_after": "2026-10-15T17:00:00-04:00" },
    "max_price": { "amount": "100.00", "currency": "CAD" },
    "max_bookings": 1
  },
  "consent": { "mode": "human_present", "statement": "Book my oil change Thursday afternoon, under $100." },
  "contact": { "name": "Sam R.", "phone": "+1-416-555-0100" },
  "status": "https://keyapp.example/status/m-01J9Z…",
  "attestations": ["<compact JWS, typ schcp-attestation+jwt>"]
}

attestations (root mandates only, at most four) carries third-party statements about the principal key (§7.5). A closed mandate adds constraints.offer_sha256 (§5.3). aud may be a list of host ids (below), and constraints may add shared_limit: true. A mandate carries no payment instrument and no spending authority: max_price is a ceiling on what the human agrees a host may state, not authority to pay it (§8). There is no constraint for cancellation or no-show fees, because no offer states one (§7.6).

The host checks, on every action:

  1. The signature verifies against principal_jwk, and iss is that key's thumbprint (self-certifying). Agents and hosts MAY additionally pin principals through an identity provider. §3.4 profiles AP2's verifiable-credential format (SD-JWT VC) as an optional alternative.
  2. aud equals the host id, or is a list that contains it. A "*" (alone or in a list) matches any host; agents SHOULD NOT request wildcard mandates and hosts MAY refuse them (the reference endpoint refuses them unless configured otherwise).
  3. iat ≤ now + 60 s and now < exp. Verifiers MUST allow 60 seconds of clock skew on iat, and no more, so independent implementations accept and refuse the same mandates. exp gets no skew. A passed exp is reason mandate_expired, so the agent knows to ask for a fresh mandate; every other verification failure is mandate_invalid.
  4. cnf.jkt equals the thumbprint of the key that signed the HTTP request. A stolen mandate is useless without the agent's key.
  5. The action is in scope.
  6. The action satisfies constraints: service, slot inside window (start ≥ not_before and end ≤ not_after, both inclusive; for an open-ended booking, its start for both; §4.2), the offer's stated all-in total ≤ max_price (an offer that states no price always fits), and active bookings made under this jti < max_bookings (across every host it names, for a shared booking limit; below).
  7. The mandate is not revoked. The host MAY consult status. Revocation is best-effort, so short exp values are the primary control.

The mandate is sent in the SchCP-Mandate header.

One consent, several hosts. A principal shopping around ("book one oil change at either of these shops") signs one mandate whose aud lists both. So does one arranging a meeting with another person, whose mandate names every member's calendar (§5.12). Each host counts max_bookings only against its own bookings, so on its own a list would let the agent book once at each. constraints.shared_limit: true makes max_bookings one limit shared across every host the mandate names, which the principal's key app keeps:

The claim id names the host and hold, so the same confirm can never spend two units.

Management mandates. Buying authority should be short-lived, but a booking lives for days or weeks and may need to be cancelled or moved. A principal therefore issues, at confirmation time, a management mandate: scope ["cancel", "reschedule"], constraints.bookings: ["b_…"], and valid until the appointment ends. A mandate that carries constraints.bookings MUST NOT authorize any action except on the listed bookings. In particular it cannot place holds. Constraints bind reads too: GET /bookings/{id} and GET /holds/{id} need no particular scope, but the booking or hold must fall within every link's bookings, services and other constraints, and releasing a hold is checked against that hold's service, time and price.

Renewal. A management mandate's lifetime is fixed when it is issued (the reference key app uses the booking's end plus one hour). Moving the booking later does not extend it, and hosts MUST NOT treat a reschedule as renewing any credential. After a reschedule, the key app SHOULD issue a fresh management mandate for the same booking id and the same narrow scope, valid until the new end, and only after verifying the host-signed receipt of the moved booking. Renewal never widens authority: it carries no hold or book scope.

Claims are validated. Every mandate in a chain MUST match the MandateClaims schema (spec/schemas/schcp.schema.json) before it is used, with format asserted (§2.1). Unknown claims and constraint names, at any depth, unparseable dates and wrongly typed limits make the mandate invalid (401 MANDATE_INVALID). They never weaken it.

Ids are per link. jti is chosen by whoever signs the mandate, so max_bookings usage MUST be keyed by (root iss, jti), never by jti alone. Each link of a chain is its own credential with its own status, so a chain whose links repeat a jti is invalid, links are checked root first, and a revocation result MUST be cached per link, keyed by that link's own status URL (with its iss and jti). Otherwise a delegate could give its child its parent's jti and a status URL it controls, and the child's "active" answer would stand in for the revoked root.

Status URLs. A host fetches status URLs on a caller's behalf, so it MUST apply the same outbound policy as for webhook callbacks (§5.8).

Attenuation. A mandate whose scope includes "delegate" lets the consumer agent issue a child mandate to a sub-agent. Chains are limited to depth 2: a root and at most one child. The child is a mandate like any other (typ: schcp-mandate+jwt, the MandateClaims schema) except:

The host verifies the chain from the leaf: it decodes each link, then checks the root first and each child after it. A chain is invalid (401 MANDATE_INVALID) unless:

A child's constraints are not compared with its parent's. Instead, checks 5 and 6, and the bookings and offer_sha256 rules below, apply to every link, so an action must fit the root's constraints and the child's. A child that names a wider limit than its parent (a higher max_price, a longer window) gains nothing: the parent's limit still refuses the action, with 403 (MANDATE_SCOPE or MANDATE_LIMIT) when the action is checked, never at verification. A child that leaves a constraint out is likewise bound by the parent's. max_bookings counts per link, each under its own (root iss, jti), and a booking counts against every link that has one.

3.4 AP2 profile: SD-JWT VC mandates (optional)

A host that lists "dc+sd-jwt" in auth.mandates also accepts mandates as SD-JWT VCs with Key Binding (RFC 9901, SD-JWT VC draft), the format of AP2 v0.2's Agent Authorization Framework. The authority they carry, and every check of §3.3 steps 2–7, is the same; only the envelope differs. SchCP uses AP2's credential format only: no AP2 payment mandate, cart or payment method is carried or accepted (§8). A verifier tells the formats apart by ~, which a compact JWS never contains. The SchCP-Mandate header carries the whole presentation, ~ separators and Key Binding JWT included, and the request signature (§3.2) covers it byte for byte.

Keys. In this profile only, principals may use Ed25519 (EdDSA) or P-256 (ES256, raw r||s signatures, the keys secure enclaves and WebAuthn hold). Everything else SchCP signs is Ed25519 only: compact mandates and their chains (§3.3), meeting contracts, acceptances and rules (§5.12), and host keys, whose Jwk schema is OKP/Ed25519; a compact mandate signed with a P-256 key is 401 MANDATE_INVALID. The algorithm follows from the key and is never negotiated, and a JWS whose alg is not the key's is invalid (none always is). RFC 7638 thumbprints cover {crv,kty,x} for OKP keys and {crv,kty,x,y} for EC keys. Request signing (§3.2) stays Ed25519-only, so an agent that signs HTTP requests binds its Ed25519 key in cnf.

Issuer-signed JWT. Header: typ: "dc+sd-jwt", alg per the key, and jwk, the principal's public key. As in §3.3 the principal is self-certifying: iss MUST be urn:schcp:principal:jwk-thumbprint: plus the thumbprint of the header jwk. Claims:

Claim Disclosure Meaning
vct always org.schcp.mandate.booking.open.1, or org.schcp.mandate.booking.closed.1 when the constraints carry offer_sha256 (exact match; any other value is invalid)
iss, sub, iat, exp, jti always as in §3.3; nbf optional
_sd_alg always sha-256 (the only algorithm accepted)
cnf.jwk always the agent's public key (RFC 7800)
scope always as in §3.3; delegate is not allowed (no chains in this profile)
constraints always limits that hold at every host
aud per element an array of host ids, each element selectively disclosable
host_constraints per member an object keyed by host id, each member selectively disclosable: that host's limits (its service ids, for instance)
consent, contact per claim selectively disclosable; the holder may withhold them
schcp_status always the §3.3 status URL
attestations always as in §3.3 (§7.5)

The issuer MUST create a host_constraints member (possibly {}) for every audience, MUST NOT set a constraint in both places, and SHOULD pad aud and host_constraints with decoy digests so their length does not count the hosts. A closed mandate names exactly one host. aud holds no wildcard.

Selective disclosure. Only allowlist entries are disclosable, never limits: hiding an entry can only narrow what a mandate allows. A mandate covering three garages is presented to each with that garage's aud element and host_constraints member and nothing about the other two. The verifier MUST reject a presentation that does not disclose its own host_constraints member, so withholding a host's limits fails closed. The mandate the verifier evaluates is: aud = its own id, constraints = the shared constraints plus its own member, cnf.jkt = the thumbprint of cnf.jwk, status = schcp_status. It is validated against MandateClaims like any mandate, so unknown constraint names make it invalid.

Key Binding JWT. Every presentation MUST end in a KB-JWT, whether or not the holder chose to send one. Header typ: "kb+jwt", signed by cnf.jwk; claims aud (a single string, the host id), nonce, iat and sd_hash (base64url SHA-256 over the issuer JWT and the presented disclosures, each followed by ~). The verifier MUST check the signature with cnf.jwk, that aud equals its own id, that iat is no more than 300 s old and no more than 60 s in the future, and that sd_hash matches. SchCP has no challenge step, so the agent chooses the nonce. Replay is bounded by the 300 s window and by §3.2: cnf.jwk MUST be the key that signed the HTTP request, and each request signature is single-use. Agents therefore present a fresh KB-JWT with every request.

Verification follows RFC 9901 §7.1 and §7.3: verify the issuer signature, recompute each disclosure's digest over its base64url form, reject repeated digests, disclosures in the wrong position, duplicate claim names and disclosures the payload does not reference, then remove _sd, _sd_alg and undisclosed array elements.

Not in this profile. Token Status Lists (a mandate with a status claim is rejected rather than accepted unchecked; revocation uses schcp_status), x5c or issuer-metadata key resolution (the issuer key is self-certifying), the Delegate SD-JWT chain AP2 uses to close open mandates with the agent's key, and Mandate Receipts. A closed mandate here is a new credential signed by the principal over the offer, the stricter profile §5.3 requires.

4. Resources and availability

4.1 Resources, capacity and parties

The host has resources (bay #1, Alex the technician, chair 3, or the person themself), each with a resource_type and a working schedule. A service requires one resource of each listed type for duration + buffer_after. A slot is available when every requirement can be met by a distinct resource that is free, and within its working hours, for the whole interval including the buffer. Holds and bookings both occupy resources. The endpoint MUST allocate resources atomically, so two concurrent holds can never take the same resource for overlapping time.

Resources are internal. The protocol exposes slots, not resource identities, unless the service lets the guest choose a resource ("selectable": true, e.g. picking a barber).

Capacity and parties. A resource may have a capacity greater than one: a spin studio seats 20, an instructor teaches 20. A booking takes as many units of a shared resource as it has people (party_size, default 1), and the resource is free while its booked units fit. A requirement marked "combine": true may be met by several free resources whose capacities add up to the party (two 4-tops pushed together for six); the endpoint takes the largest first and allocates them in one transaction with the rest of the booking. A service states party_size: {min, max}; a request outside it is 400 INVALID_REQUEST. With price_per: "person", the service line is the price times the party, and the total follows.

4.2 Open-ended bookings

Some meetings have a start and no end anyone can promise: a car dropped off for diagnosis, a consultation that runs as long as it runs, a playdate. A service with "duration": "open" is booked for a start only.

5. Operations

All operation paths are relative to endpoint.

5.1 POST /availability

{ "service_id": "oil-change",
  "window": { "start": "2026-10-15T12:00:00-04:00", "end": "2026-10-15T17:00:00-04:00" },
  "constraints": { "resource": null, "granularity": "PT15M" },
  "limit": 20 }

→ 200

{ "service_id": "oil-change", "timezone": "America/Toronto",
  "slots": [ { "start": "2026-10-15T13:00:00-04:00", "end": "2026-10-15T13:45:00-04:00",
               "price": { "amount": "79.00", "currency": "CAD" },
               "total": { "amount": "89.27", "currency": "CAD" } } ],
  "as_of": "2026-10-13T21:00:00Z" }

A slot of a service with no price has no price or total; a slot of an open-ended service has "open_end": true and no end (§4.2): { "start": "2026-10-17T12:00:00-04:00", "open_end": true }.

Availability is a snapshot, not a promise. A slot's total is the stated all-in price, including any surcharge that applies at that time; the offer states the binding total. A request MAY give party_size; slots are then those with room for the party. Slots are offered only where a hold would be granted: inside the booking window and release schedule, outside the walk-in reserve (§7.2), and within what the host's availability source has actually been read for (§10). Mandates are optional here (availability is public), but the request MUST be signed so that it can be rate-limited per agent. At a contacts-only host (§7.8) availability is not public: the request MUST carry a mandate, which shows who is asking, and a principal not on the host's list is refused with 403 NOT_PERMITTED, reason not_a_contact.

5.2 POST /holds — place a hold

Requires a mandate with hold in scope.

{ "service_id": "oil-change", "start": "2026-10-15T13:00:00-04:00",
  "ttl": "PT5M", "idempotency_key": "6b0c…",
  "callback_url": "https://agent.example/hooks/schcp", "party_size": 1 }

→ 201

{ "hold_id": "h_…", "status": "held", "service_id": "oil-change",
  "start": "…", "end": "…", "expires_at": "2026-10-13T21:05:00Z",
  "price": { "amount": "79.00", "currency": "CAD" },
  "total": { "amount": "89.27", "currency": "CAD" },
  "offer": "<compact JWS, typ schcp-offer+jwt>" }

The request schema is closed: a hold carrying any field it does not define, payment included, is 400 INVALID_REQUEST (§8). So is the response: it MUST match the Hold schema, which allows no member it does not define and requires the offer. The same shape is returned by GET and DELETE /holds/{id} and carried by hold.expired and waitlist.offered webhooks. An agent MUST refuse a hold that fails.

The offer is the host's signed statement of the terms behind the hold, and the contract the booking will stand on. The agent MUST verify it against the discovery keys, and it SHOULD show the human the offer's terms, not its own paraphrase of them. The offer plays the part of AP2's merchant-signed cart, with no payment attached. The endpoint signs it once, when the hold is created, and returns the same bytes for the life of the hold, because every acceptance names those bytes by hash (§5.3).

Offers are self-contained. Every term that governs the booking is in the offer itself, never behind a mutable URL such as the discovery document:

{ "v": "0.4", "kind": "booking", "iss": "maple-street-auto", "hold_id": "h_…",
  "iat": 1792000000, "exp": 1792000300,
  "host": { "name": "Maple Street Auto", "tz": "America/Toronto", "domain": "maplestreetauto.example" },
  "service": { "id": "oil-change", "name": "Synthetic oil change" },
  "slot": { "start": "2026-10-15T13:00:00-04:00", "end": "2026-10-15T13:45:00-04:00" },
  "price": { "amount": "79.00", "currency": "CAD" },
  "total": { "amount": "89.27", "currency": "CAD" },
  "lines": [ { "kind": "service", "label": "Synthetic oil change", "amount": { "amount": "79.00", "currency": "CAD" } },
             { "kind": "tax", "label": "HST 13%", "amount": { "amount": "10.27", "currency": "CAD" } } ],
  "retention": "P365D" }

An offer for a playdate on a person's calendar (§7.7) has no money in it at all, and no end:

{ "v": "0.4", "kind": "booking", "iss": "ellis-moreau", "hold_id": "h_…",
  "iat": 1792000000, "exp": 1792000900,
  "host": { "name": "Ellis Moreau", "tz": "America/Toronto", "kind": "person",
            "owner": "urn:schcp:principal:jwk-thumbprint:Ellis…" },
  "service": { "id": "playdate", "name": "Playdate" },
  "slot": { "start": "2026-10-17T12:00:00-04:00" }, "open_end": true,
  "place": { "name": "Trinity Bellwoods Park playground", "locality": "Toronto" },
  "approval_by": "2026-10-14T01:15:00Z", "retention": "P30D" }

An accepted offer's terms bind the booking. When the hold is confirmed, the booking stands on the offer: its time, place, price lines, retention and any reconfirmation it states (§5.10) are the offer's, and the endpoint MUST use them, not its current policy, for everything later. A policy change, before or after confirmation, applies only to offers made after it. Checking the hash of an approved offer is not enough on its own; execution has to follow the offer too.

Offer claims SHOULD include host: { "name", "tz", "domain"?, "address"?, "kind"?, "owner"? }: name (the business's or person's name) and tz (an IANA time zone) are required inside it; domain is the domain the host was discovered at (§2), address is a one-line postal address, and kind is "person" for a person's calendar, which also names its owner's principal as owner so a meeting contract can name them (§5.12). Hosts SHOULD include it in every signed offer. Key apps SHOULD render the slot as a local date and time in words in host.tz and show host.name with domain in parentheses, because the domain is what the human can check against where they meant to book (the reference key app renders "Cleaning and check-up with Harbourview Dental (harbourviewdental.example), Thursday 15 October 2026, 8:00–9:00 a.m. Pacific time, CAD 180.00, paid to Harbourview Dental directly"). A stated price is always rendered as paid to the host directly, because nothing is paid through SchCP; an offer with no price renders no money at all. A key app MUST NOT show a domain that differs from the one it discovered the host at; it shows the discovered domain instead. When domain is absent key apps show iss; when host is absent they fall back to iss and the slot's UTC offset. A slot that cannot be held → 409 SLOT_UNAVAILABLE (reason slot_taken), with up to five alternatives (nearest available slots). This is the analogue of iTIP COUNTER: the host's answer to "no" is a counter-proposal.

The granted TTL is min(requested, policy max, reputation cap) (§7.3).

Who, when and where. Before any resource is looked at, the endpoint checks the request against the service's published rules. Each refusal is specific, so the agent can tell the human why:

The hold echoes party_size and place. A hold never carries payment data, and nothing about it depends on the guest having paid (§8).

5.3 POST /holds/{hold_id}/confirm — book

Requires a mandate with book in scope, the same principal (iss) as the hold, the hold not expired, and, at a contacts-only host, a principal still on the host's list (§7.8).

Confirming involves nothing outside the endpoint's own store, so the endpoint MUST check the hold, the offer hash, the mandate and every policy, and write the booking, in one transaction. There is no window in which the hold can lapse or be taken between the check and the write.

{ "idempotency_key": "…", "offer_sha256": "Xy3…", "contact": { "name": "Sam R.", "phone": "+1…" },
  "notes": "2019 Civic" }

Every acceptance names its contract. offer_sha256 is the base64url SHA-256 of the exact compact JWS of the hold's offer, with no JSON canonicalisation. It is REQUIRED: a request without it fails schema validation (400 INVALID_REQUEST, §3.1). A different hash is refused with 409 CONTRACT_MISMATCH (reason contract_mismatch), whose details give the hold's offer_sha256. The agent therefore always commits to the exact bytes the host signed; whether the human must also sign them is the separate closed-mandate policy below. The same rule applies to every other acceptance: a change offer (§5.7) and a host proposal (§5.8).

Free text. notes is the one place an agent can put anything at all, including health information a clinic must not receive by that route. A host that publishes policies.data.free_text_notes: "refuse" refuses a confirm carrying notes with 422 DATA_NOT_ACCEPTED, reason free_text_notes, before storing anything; its contact remedy points to data.intake_url, the host's own form. Agents SHOULD read the policy and not send notes at all.

Meetings and who answers. meeting carries a meeting contract when this booking is one member's part of a meeting of several people (§5.12). answers: "person" asks that the host's own person say yes, not a rule (§7.9).

→ 201 (instant confirmation) or 202 (pending_host)

{ "booking": { "booking_id": "b_…", "status": "confirmed", "sequence": 0,
    "service_id": "oil-change", "start": "…", "end": "…",
    "price": {…}, "total": {…}, "lines": [ … ], "offer_sha256": "Xy3…",
    "event": { "@type": "Event", "uid": "…",
    "start": "2026-10-15T13:00:00", "timeZone": "America/Toronto",
    "duration": "PT45M", "title": "Synthetic oil change — Maple Street Auto", "sequence": 0,
    "updated": "2026-10-13T13:00:00Z", "status": "confirmed" } },
  "receipt": "<compact JWS signed by the host key>" }

Closed mandates. An open mandate says what the agent may agree to. A closed mandate says what the human did agree to: it carries constraints.offer_sha256, the base64url SHA-256 of the offer's compact JWS, and typically scope ["book"] (["reschedule"] for a change offer), a window equal to the slot (its start, for an open-ended offer) and, when the offer states a price, a max_price equal to the total. A mandate whose constraints include offer_sha256 authorizes accepting only the offer with that hash (book, or reschedule and a proposal response for a change offer) and reading what it accepts (a GET of the hold or booking, within its other constraints). Every other action, releasing the hold included, fails with 403 MANDATE_LIMIT; an agent releases a hold under the open mandate it placed it with.

The root mandate of the chain (the one the principal signed) MUST carry the matching offer_sha256 when policies.closed_mandate is "always". An agent-signed child carrying the hash does not count, because the point is evidence that the human saw these terms. Otherwise the acceptance fails with 403 OFFER_APPROVAL_REQUIRED (reason closed_approval_required), whose details give offer_sha256 and whose remedy is approve_offer. This applies to change offers too (§5.7). Under "never" an open mandate that covers the offer is enough. (v0.3's "if_charged", and the "if_no_show_fee" of earlier v0.4 drafts, are gone with the fees they keyed on, §7.6; a host that wants the human to see every offer uses "always".)

The consent trust boundary. A signature binds bytes, not what a screen showed. A closed mandate is evidence of the human's approval only if the surface that displays the terms and the surface that controls the principal key are the same trusted component (the key app), and that component derives the complete text it shows from the same authenticated offer bytes it hashes. The consent surface MUST therefore generate the text from the offer's own claims, MUST show every term that commits the human (the time, the place, any stated price, any reconfirmation: when it is due and what silence does, and how long the host keeps the booking's records), and MUST NOT expose a "sign this hash" operation to the agent. An agent that edits the offer before passing it to such a key app gets a signature over different bytes, and the host refuses it. An agent that controls both the display and the signing key can show misleading prose over the original bytes; no protocol check can detect that, which is why the key belongs to the key app. The business identity shown SHOULD be the domain verified during discovery; when the key app has none, it MUST label the offer's claimed identity as unverified.

What an assistant shows. With no price to anchor attention, the consent text is the whole of what a person reads before saying yes. An assistant that relays an offer:

A receipt is evidence of the agreement. Every receipt names the contract the booking stands on (offer_sha256), how long the host keeps its records (retention, the contract's own), and how the person agreed to it (consent):

The receipt's offer_sha256 and its booking's offer_sha256 are the same hash, and an agent MUST refuse a receipt where they differ. A booking made outside SchCP (on the host's own website, or by phone) has no contract: its offer_sha256 is null, the receipt has none, and it has no consent. The agent that made or changed a booking MUST refuse a receipt that names another mandate than the one it used, or a retention other than its contract's. LLMs never author the contract: terms are structured, host-signed data, natural-language negotiation binds nothing, and consent text is a deterministic rendering of the offer.

The event member is a JSCalendar (RFC 8984) Event so that the agent can write it straight into the principal's calendar. The receipt is a JWS (typ schcp-receipt+jwt) over the booking, signed by a host key from the discovery document. It is the principal's proof that the booking exists. Its claims MUST match the ReceiptClaims schema, and the booking in it the Booking schema: the same shape GET /bookings/{id} and webhooks return, with any open change as a Proposal. Both are closed, the JSCalendar event too, which carries only the RFC 8984 properties the schema lists. An agent MUST refuse a receipt that fails, even one the host's key signed.

The response around it (booking, receipt, and the booking's current contract as offer and a meeting's contract as contract where they apply) MUST match the closed BookingResponse schema. Every operation that returns a booking returns this shape: confirm, GET, cancel, reschedule, respond, proposal-response, commit and meeting revision. An agent MUST refuse a response that fails.

5.4 DELETE /holds/{hold_id} — release

Idempotent. Releases the resources.

5.5 GET /bookings/{booking_id}

Returns the current booking. Requires the mandate of the principal that owns the booking (any scope). Besides the fields above, a booking carries refusal (why the host declined, cancelled or let it lapse, §9.2), proposal (an open change offer, or a guest's accepted move with awaiting_host, §5.7), declined_change (the guest's last move the host declined or did not answer, §5.7), answer (whether the host itself, the person whose calendar it is, or a rule of theirs answered, §7.9) and meeting (the contract hash a meeting booking stands on, and its member's acceptance, §5.12). For a meeting booking the response also carries the contract itself as contract.

A required participant (§5.10) may read the booking too, and gets the same response as the booker, so they can check the contract they are asked to accept.

A booking carries no payment state: no deposit, capture or refund, because none happens through SchCP (§8). Nor does it carry any account of cancellations or no-shows: no fee terms, records or undo deadline (§7.6). An open-ended booking (§4.2) has open_end: true and no end until the host ends it.

5.6 POST /bookings/{booking_id}/cancel

{ "idempotency_key": "…", "reason": "…" }

Requires a mandate with cancel in scope. reason is optional free text; the endpoint passes it to the host with the cancellation, so its people learn why. An organizer's agent that calls a meeting off on the other calendars SHOULD say why (§5.12).

Before the start, cancelling always succeeds, never costs anything and records nothing. A confirmed, pending_host or tentative booking whose start has not passed is cancelled → 200 with status: "cancelled", sequence + 1, and a fresh receipt. However close to the start it comes, the endpoint MUST NOT attach a fee or a record of the cancellation to the booking, its receipt or any later offer. Once the start has passed, cancelling is 409 INVALID_STATE, reason already_started; what happens then is attendance, which the host declares (§6).

There is no quote and no undo window: with nothing to charge or record, every cancellation is already free. A host's own late-cancellation policy, if it has one, is between the host and the guest, outside SchCP (§7.6, §8).

A tentative booking that never became final counts, when cancelled, like a released hold for reputation (§7.3, §5.10).

5.7 Changes: POST /bookings/{booking_id}/change-offer and /reschedule

A change is a new contract, so it takes two steps.

  1. Ask for terms. POST /bookings/{id}/change-offer { "idempotency_key", "sequence", "start", "place" } (scope reschedule). start and place are each optional, but a request names at least one: a new place keeps the start, for a meeting somewhere else (§5.2). The host holds the new time (TTL as for a hold, reputation-scaled) and returns 201 with a proposal: { "proposal_id", "start", "end", "expires_at", "by": "guest", "place", "offer" } (place when the change has one; no end for an open-ended booking, which sets aside time afresh at the new start). The offer has kind: "change", the booking_id, the booking's terms and total, and prev: the SHA-256 of the booking's current contract, so each change extends a hash chain either side can verify. Like any offer it carries no fee terms (§5.2): moving a booking through SchCP is free however late it is asked, and accepting the change records nothing. The booking keeps its old time until the offer is accepted. A booking has at most one open change offer; asking again replaces it.
  2. Accept it. POST /bookings/{id}/reschedule { "idempotency_key", "sequence", "offer_sha256" }. sequence MUST equal the booking's current sequence, otherwise 409 STALE_SEQUENCE (iTIP's rule: stale updates are ignored). The hash MUST name the open change offer, otherwise 409 CONTRACT_MISMATCH. Acceptance is atomic: the held new time becomes the booking's and the old one is released in one transaction, so a failure leaves the original booking intact. → 200, sequence + 1, the change offer as the booking's contract (its total and lines become the booking's), and a new receipt.
  3. The host's yes, where it approves each booking. A host_approval host approves a guest's move as it approves a new booking, unless the booker is its owner or a rule its person signed covers the new time (§7.9; then answer names the rule). Accepting then returns 200 with the booking unchanged: its old time, contract and sequence, and the accepted change offer as proposal with awaiting_host: true and consent, how the guest agreed to the move (§5.3), which the receipt carries once the move is made. The new time stays held until expires_at, the host's approval_timeout (default two hours) and never past the booking's own start. Accepting again returns the same. When the host says yes, the move happens atomically as in step 2, answer records who said yes, and the guest gets booking.change_approved (§5.8). When it says no or does not answer in time, the booking keeps its time, the held new time is released, and the guest gets booking.change_declined with a refusal: reason host_declined:*, or host_did_not_answer under EXPIRED. The booking also records it as declined_change (proposal_id, start, place, refusal), so a guest that polls learns why; asking for another change clears it. A move the host never answered does not count against the guest's reputation.

What the booking (and so each receipt) shows at each stage of a guest's move at a host that approves it:

Stage status, start, offer_sha256 sequence proposal declined_change receipt consent
Waiting for the host the booking's own, unchanged unchanged the change offer, awaiting_host: true, its consent (how the guest agreed to the move) none the booking's original
Moved (the host said yes) confirmed, the new start, the change offer's hash + 1 none none the move's: mandate is the mandate that accepted the change
Declined, or lapsed (host_did_not_answer) unchanged unchanged none proposal_id, start, place, refusal the booking's original
Dropped (the booking cancelled while waiting) cancelled + 1 none none the booking's original

An agent MUST refuse a receipt that shows a move waiting when its proposal.expires_at is after the booking's start, or its proposal.offer is not the change offer the agent accepted.

Recording a declined or lapsed move does not bump sequence: nothing the guest agreed to changed, so the agent's next acceptance still names the same sequence. The host still signs a fresh receipt, sent in booking.change_declined, whose refusal MUST equal the booking's declined_change.refusal. A moved booking's answer says who said yes.

A guest's move at a host that approves each booking: the booking keeps its time until the host answers

An open guest change offer holds time like a hold, so it counts against the agent's max_active_per_agent (asking is refused with 429 HOLD_LIMIT at the cap), and one that lapses unaccepted counts as an expired hold for reputation (§7.3).

A host with closed_mandate: "always" requires the principal's signature on change offers too (§5.3). Moving within one's own time slot (30 minutes later with the same barber) works because the change offer's hold may overlap the booking it belongs to, and nothing else.

5.8 Host-initiated changes (webhooks)

When the host changes a booking (cancels because a bay is broken, proposes a new time, or approves/declines a pending_host booking), the endpoint POSTs a signed event to the hold's callback_url:

{ "event_id": "e_…", "type": "booking.cancelled_by_host",
  "booking": { … }, "alternatives": [ … ], "occurred_at": "…" }

The request is signed (RFC 9421) with the host key from the discovery document. Types: booking.confirmed, booking.declined, booking.expired (the host never approved it), booking.cancelled_by_host, booking.change_proposed, booking.ended (an open-ended booking ended, §4.2), booking.no_show, hold.expired, booking.change_approved and booking.change_declined (the host's answer to a guest's move, §5.7; the second carries a refusal), booking.response (§5.10), booking.accepted (a meeting member accepted, §5.12), waitlist.offered (§5.9). Deliveries carry host_id and the SchCP-Host header naming the host. Every booking.* event carries a signed receipt of the booking as it now stands, so the agent holds portable evidence of each change; HTTP message signatures authenticate the delivery but are not a substitute. An event MUST match the WebhookEvent schema; the booking, hold and waitlist it carries are the closed Booking, Hold and WaitlistEntry, and an agent refuses an event that fails. A booking.change_proposed event does not change the booking (its receipt is of the unchanged booking, with the proposal attached). The proposal is a change offer (§5.7) with by: "host". A booking.change_proposed event is answered with POST /bookings/{id}/proposal-response { "idempotency_key", "proposal_id", "accept": true|false, "sequence", "offer_sha256" }, which is iTIP's COUNTER/REPLY pair; offer_sha256 is required when accepting. A host that declines or cancels a booking says why in the booking's refusal (§9.2).

callback_url MUST be a public https URL. Otherwise the protocol would hand every caller a server-side request forgery primitive. Checking the URL's text is not enough, because any public hostname can resolve to a private address, and can change its answer between a check and the connection. The endpoint therefore MUST check the address it actually connects to, at connect time: it refuses private, loopback, link-local, CGNAT, multicast and reserved addresses (IPv4 and IPv6, including IPv4-mapped), URLs with credentials and non-https URLs, uses short timeouts, bounds response size, and does not follow redirects. The reference implementation's default transport (publicOnlyFetch) runs this check inside the socket's own DNS lookup.

Webhooks are an optimization. Agents MUST be able to poll GET /bookings/{id} instead.

Extensions. A job can grow: the caliper bolts are seized, the colour needs another 30 minutes. The host extends a confirmed booking with a host change offer whose slot keeps the start and moves the end, and whose lines add the extra work (a fee line) with tax recomputed. It holds the extra time like any proposal and reaches the guest as booking.change_proposed. Accepting it is an acceptance like any other: it names the change offer's hash, needs the principal's signature where §5.3 says so, and the new total must fit max_price. The booking adopts the new end, total and lines, and the guest gets a new receipt. Declining keeps the original booking; the host then decides what happens to the work, off-protocol. With no price stated, an extension changes only the time (and any extra amount becomes the only line and the total, §5.2). An open-ended booking has no end to extend; an attempt is 400 INVALID_REQUEST.

Displaced bookings. When a resource goes out (a technician calls in sick, a lift breaks, or a calendar sync shows the time busy), the endpoint MUST NOT leave confirmed bookings silently overbooked. Each affected booking is moved to another free resource of the same type when one exists and the guest did not choose that resource; that changes nothing the guest agreed to, so it needs no answer. Otherwise the host offers the nearest other time as a host change offer, lasting at most until the booking would have started. Bookings with neither are reported to the host as unresolved.

5.9 Waitlist: POST /waitlist, GET and DELETE /waitlist/{id}

A host that publishes policies.waitlist: { "offer_ttl": "PT15M", "max_entries_per_agent": 2 } (and the waitlist capability) keeps a waitlist per service.

{ "idempotency_key": "…", "service_id": "haircut",
  "window": { "start": "2026-10-15T16:00:00-04:00", "end": "2026-10-15T19:00:00-04:00" },
  "callback_url": "https://agent.example/hooks/schcp" }

→ 201 { "waitlist_id": "w_…", "status": "waiting", "service_id", "window", "position": 1, "created_at" }

The entry MUST match the closed WaitlistEntry schema, here, from GET and DELETE /waitlist/{id}, and in a waitlist.offered webhook.

Entry statuses: waiting, offered, booked, left, lapsed, and expired (the window passed, or the mandate expired before an offer).

5.10 Participants: POST /bookings/{id}/respond

A booking may name people other than the signer. A confirm request can carry participants:

"participants": [
  { "who": "urn:schcp:principal:jwk-thumbprint:Ab3…", "role": "invitee", "relationship": "brother",
    "confirm_within": "PT2H", "default": "release" },
  { "name": "Mira", "role": "attendee", "relationship": "child" } ]

role is attendee (someone who comes along) or invitee (someone invited to the booking, typically with who); the endpoint adds the booker's own reconfirmation as role: "booker" (below).

Reconfirmation reuses the mechanism. A host that publishes policies.reconfirm: { "before": "PT24H", "default": "release" } states it in every offer (reconfirm), so it is part of the contract the human approved; a policy added later binds only later offers. The booking's terms add the booker as a required participant (role: "booker") with confirm_by at start minus before, when that is still in the future; at a host that approves bookings, from the moment it approves. A change recomputes the deadline from the new start. The booking stays confirmed meanwhile, and one whose start has passed is never released for a missed reconfirmation. The booker answers with respond and accept: true (a management mandate will do); to back out, they cancel. If nobody answers, default applies: "release" lets the booking lapse (expired, reason not_reconfirmed), freeing the time. Like a cancellation (§5.6), the lapse costs nothing and records nothing, however close to the start the deadline falls; what a host makes of a guest who does not reconfirm is its own business (§7.6).

5.11 Counters: the host answers with other terms

The guest names the place (§5.2); the other side may counter it. A host whose booking is pending_host MAY answer with other terms instead of yes or no: a different place, a different start, or both.

A confirmed booking's place can change too: a host proposal (§5.8) and a guest change offer (§5.7) may each name a new place.

5.12 Meetings of several people

Two or more people meet, each with a calendar of their own: a host of kind "person" (§7.7) whose owner is that person. One of them, the organizer, has their agent arrange it with the operations above plus two more. The organizer is on every other member's contact list (§7.8). A meeting of two is the smallest case, and works the same way. A business that takes part in meetings may be a member too (a café holding the table); its signed offer is its yes.

The flow. The organizer's key app issues one mandate naming every member's calendar (aud is a list, §3.3) with scope hold, book, cancel and reschedule. The agent then:

  1. reads availability at each calendar and keeps the starts all of them offer;
  2. holds that time at each, with the place where the meeting is elsewhere;
  3. writes the meeting contract below over the holds' signed offers;
  4. has the organizer's human approve its text once (the key app's signature over the contract is that approval);
  5. confirms each hold with the contract;
  6. once every member has accepted, commits at every calendar.

Nothing is confirmed on any calendar until every member has said yes to the same contract hash.

A meeting of three: holds, one contract, each member's yes, commit

The meeting contract is a compact JWS, typ schcp-meeting+jwt, signed by the organizer's own key:

{ "v": "0.4", "iss": "urn:schcp:principal:jwk-thumbprint:Noor…", "principal_jwk": { … },
  "iat": 1792154400, "exp": 1792197600,
  "meeting_id": "mtg_…", "revision": 1, "title": "Playdate", "tz": "America/Toronto",
  "start": "2026-10-17T12:00:00-04:00", "open_end": true,
  "place": { "name": "Trinity Bellwoods Park playground", "locality": "Toronto" },
  "members": [
    { "principal": "urn:…:Noor…", "name": "Noor Haddad", "host": "noor-haddad", "domain": null,
      "service_id": "playdate", "offer_sha256": "…" },
    { "principal": "urn:…:Ellis…", "name": "Ellis Moreau", "host": "ellis-moreau", "domain": null,
      "service_id": "playdate", "offer_sha256": "…" },
    { "principal": "urn:…:Priya…", "name": "Priya Shah", "host": "priya-shah", "domain": null,
      "service_id": "playdate", "offer_sha256": "…" } ],
  "attendees": [ { "name": "Mira", "relationship": "child" } ],
  "answers": "person_or_rule",
  "statement": "I approve: Playdate with Ellis Moreau (ellis-moreau) and Priya Shah (priya-shah), Saturday 17 October 2026 from 12:00 p.m. Eastern time, open-ended, at Trinity Bellwoods Park playground, Toronto, with Mira (child) coming along; Ellis Moreau and Priya Shah may each answer by a rule they signed." }

Confirming with a contract. POST /holds/{id}/confirm carries meeting: "<contract JWS>". Besides everything §5.3 checks, the endpoint MUST verify the following and refuse otherwise:

Each calendar checks only its own entry, against its own offer. Every calendar does the same, so a contract every calendar has accepted says what every offer says, and no calendar needs another's keys.

→ 202. The booking carries meeting: { "meeting_id", "revision", "contract_sha256" }, and the response carries the contract as contract. On the organizer's own calendar the booking is tentative: the organizer's signature is their yes. On another member's calendar it is pending_host until that member answers. A business's calendar answers by its offer: tentative at once, or, at a business that approves each booking, pending_host until it says yes as the host (answer.by: "host", with no acceptance). If a rule of theirs covers it and the contract allows rules, it is tentative at once, with the host's acceptance in meeting.acceptance (§7.9). The deadline for both is the contract's exp, or the host's approval_by if earlier. The time is held, but no booking is final: reputation credit starts at commit.

Acceptances. A member accepts with a compact JWS, typ schcp-acceptance+jwt:

{ "iss": "urn:…:Ellis…", "iat": 1792158000, "terms_sha256": "<contract_sha256>",
  "meeting_id": "mtg_…", "by": "person",
  "statement": "I accept: Playdate arranged by Noor Haddad (noor-haddad), with Priya Shah (priya-shah), Saturday 17 October 2026 from 12:00 p.m. Eastern time, open-ended, at Trinity Bellwoods Park playground, Toronto, with Mira (child) coming along.",
  "jwk": { … } }

It is valid for a member when:

meeting_id, when present, is informative: the hash is what binds the acceptance to its terms, and a verifier does not compare it.

by: "person": jwk is the member's own key (its principal is iss).

by: "rule": the contract allows rules (person_required otherwise), and rule carries a rule the same person signed (§7.9) that:

Every other failure is 400 INVALID_REQUEST, reason acceptance_invalid.

The member's agent gives the acceptance to the member's own calendar (an operation of the host, not of this API). The booking becomes tentative, carries the acceptance in meeting.acceptance, and the organizer hears booking.accepted. A person's calendar MUST NOT treat a meeting as accepted without a valid acceptance, whatever its confirmation policy. The member's agent SHOULD render the text from the contract's claims ("I accept: Playdate arranged by Noor Haddad, with Priya Shah, …, with Mira (child) coming along"), and MUST sign the contract its person was shown, never one fetched after they answered: if a revision arrived in between, it shows the new text and asks again. An acceptance names the contract's hash, so a yes to text the person never saw cannot stand for one they did.

Until the meeting is final, a member may take back a yes, their own or their rule's. The calendar declines the booking as if it had never answered, with the person's message, and the meeting goes off (§7.9: a rule answers for a person, it does not bind them against their word).

Commit. POST /bookings/{id}/commit (scope book, the organizer's mandate):

{ "idempotency_key": "…", "contract_sha256": "…", "acceptances": [ "<JWS>", "<JWS>" ] }

The endpoint MUST NOT confirm until the request carries a valid acceptance (as above) of the booking's current contract from every member who must accept: every person except the organizer. A business answers by its offer and sends none:

Committed, the booking is confirmed (sequence + 1, a new receipt), and booking.confirmed is sent. Committing a confirmed booking again returns it unchanged.

The organizer's agent commits at every calendar, businesses first. If one calendar refuses (it lapsed, say), the agent cancels the others, which costs nothing (§5.6). Hosts never talk to each other: coordination is the organizer's agent's job, and the shared hash is what makes it checkable.

Ending without a meeting. A member's calendar may decline (§9.2 DECLINED). When the contract's deadline passes, a booking still pending_host lapses as host_did_not_answer; one that is tentative lapses as expired, reason meeting_incomplete. Either way the time comes free. The organizer's agent, seeing one member's booking end, cancels the rest, and tells each calendar why (§5.6): who said no, who did not answer in time, or who backed out, in that member's own words where the booking's refusal carries a message ("The meeting is off: Priya Shah said no ("Sorry, not this weekend.")"). A no or a lapse is the cause before any cancellation, since the others' calendars are cancelled as a consequence of it.

Backing out. A member's calendar may cancel its own booking, as any host may (§5.8, booking.cancelled_by_host, reason host_cancelled with the person's message): the member backs out. This is allowed before the meeting is final and after it, so a confirmed meeting can still go off. Either way the meeting is off. The organizer's agent cancels the other calendars and quotes the member to them as it would a no ("The meeting is off: Priya Shah backed out ("…")."). Agents SHOULD tell their humans that a confirmed meeting can still go off this way.

Calling it off. The organizer calls a meeting off, before or after it is final, by cancelling its booking at every calendar (§5.6), the others first and its own last, each with the reason in the organizer's words. No calendar is then left showing a meeting its organizer has left.

Revisions. A member's calendar may counter (§5.11), or the organizer may want another place or time. The organizer revises the contract:

  1. New offers. At each calendar where the terms change, the agent obtains a change offer: the member's counter, or its own POST /bookings/{id}/change-offer. That is allowed on a meeting booking that is still pending_host or tentative.
  2. New contract. It writes the contract again, with the same meeting_id, a higher revision, and each member's offer_sha256 set to their calendar's current offer or the change offer just obtained. The organizer's human approves the new text, and the key app signs it.
  3. Hand it over. At each calendar, POST /bookings/{id}/meeting { "idempotency_key", "sequence", "contract" } (scope reschedule).

The endpoint MUST check:

The booking then adopts that offer (its time moves where the change offer held it), names the new hash, and drops any earlier acceptance. It is answered again as on confirm: tentative for the organizer's own calendar or where a rule covers it, pending_host otherwise. Every member answers the new hash; an acceptance of an earlier revision is acceptance_invalid.

A revision has the same meeting, organizer and members (each on the same calendar, a business still a business), and a higher revision than any this calendar was handed before, including one that did not happen (meeting_invalid).

Changing a confirmed meeting. A confirmed meeting keeps its time, place and contract on every calendar until every member has said yes to the change; if anyone says no, or does not answer in time, it stays exactly as it was. Nobody loses the meeting. The organizer:

  1. obtains a change offer at each calendar where the terms change (POST /bookings/{id}/change-offer is allowed on a confirmed meeting booking; the offer alone moves nothing, and accepting it directly, or a host proposal on one, is refused: meeting_invalid);
  2. writes the revision as above, with exp, the deadline for every answer, no later than the meeting's current start; its human approves the text;
  3. hands it to each calendar: POST /bookings/{id}/meeting, as above.

Besides the checks above, the endpoint MUST refuse a revision of a confirmed meeting that has started (409 INVALID_STATE, already_started) or whose exp is after its current start (meeting_invalid). The booking stays confirmed at its time. It carries the revision in meeting.pending_revision (revision, contract_sha256, expires_at, answered, and the member's acceptance once given), the response carries the revised contract as pending_contract, and the change offer it names stays held until expires_at: the revision's exp, or earlier where the host promises an answer sooner. The organizer's own calendar, a business's, and one where a rule covers it answer at once; otherwise the member answers on their own calendar with an acceptance of the revision's hash, exactly as on confirm. An acceptance of the current contract does not answer it.

Once every member has said yes, the organizer commits the revision at every calendar, businesses first and its own last: POST /bookings/{id}/commit with the revision's contract_sha256 and every acceptance of it, checked as for a first commit. The calendar then moves atomically: it adopts the change offer the revision names (or keeps its own, where its terms did not change), names the new contract, and sends booking.change_approved with sequence + 1 and a new receipt. Committing it again returns it unchanged. Until then a commit of the current hash returns the booking unchanged, and any other hash is 409 CONTRACT_MISMATCH with both hashes in details.

A member's no (§9.2 DECLINED), a member taking back their yes before their calendar moved, or the deadline passing: the calendar drops the revision. The booking keeps its time and contract, the held time comes free, and declined_change records the revision, its start and place, and the refusal; on a lapse that is EXPIRED with reason host_did_not_answer where the member never answered, meeting_incomplete where they had. The organizer hears booking.change_declined. Its agent then withdraws the revision at every other calendar: POST /bookings/{id}/meeting/withdraw { "idempotency_key", "contract_sha256", "reason" } (scope reschedule), which drops it the same way, with refusal DECLINED, reason organizer_withdrew and the organizer's words.

Calendars never talk to each other. If every member said yes but one calendar can no longer move (its revision lapsed while the others were committed), the calendars disagree: the organizer's agent MUST tell its human, and revise again or call the meeting off. Committing as soon as the last yes arrives keeps this rare.

What a member can trust. A member's calendar checks its own entry and nothing else. The other members' names and principals are the organizer's word, and their offers are hashes it cannot open. A member's agent SHOULD show each other person by the name in its own person's contacts where it knows their principal, and otherwise as someone they don't know, with the organizer's name for them only in quotes ("someone you don't know, named "Priya Shah""); it SHOULD never show only the organizer's labels. The organizer's agent does the same for its own person. A business is shown as its own signed offer names it. Every acceptance is checked against the principal the contract names; a member's yes can be forged only with their key, or their agent's key under a rule they signed.

6. State machines

Hold:     held ──confirm──▶ (booking created; hold consumed)
            │──release──▶ released
            └──ttl──────▶ expired

Booking:  pending_host ──approve──▶ confirmed
               │──host counter accepted (§5.11)──▶ confirmed
               │──decline──▶ declined
               └──timeout──▶ expired
          tentative ──all participants accept──▶ confirmed
               │──a participant declines──▶ declined
               └──deadline, default release──▶ expired
          pending_host | tentative ──cancel, or host cancel (seq+1)──▶ cancelled
          confirmed ──reschedule (seq+1)──▶ confirmed
               │──guest's move accepted at a host_approval host (§5.7)──▶
               │       confirmed, unchanged, proposal awaiting_host
               │         │──host approves (seq+1)──▶ confirmed at the new time
               │         └──host declines, or approval timeout──▶
               │                 confirmed at the old time, declined_change recorded
               │──cancel (seq+1)──▶ cancelled
               │──host cancel (seq+1)──▶ cancelled
               │──host marks attended──▶ completed
               │──host ends an open-ended booking──▶ completed (seq+1, webhook)
               └──host marks no-show──▶ no_show (seq+1, webhook)

Meeting booking (§5.12):
          pending_host ──member's acceptance──▶ tentative
               │──member declines──▶ declined
               └──contract deadline──▶ expired (host_did_not_answer)
          tentative ──commit (every acceptance)──▶ confirmed
               │──member takes back their yes (seq+1)──▶ declined
               └──contract deadline──▶ expired (meeting_incomplete)
          pending_host | tentative ──revision (seq+1)──▶ pending_host | tentative
          confirmed ──revision──▶ confirmed, unchanged, pending_revision
               │──every member's yes, commit (seq+1)──▶ confirmed at the revision's terms
               └──a no, a lapse, or withdrawn──▶ confirmed, unchanged, declined_change recorded
          pending_host | tentative | confirmed
               ──organizer calls it off, or the member backs out (seq+1)──▶ cancelled
          confirmed: no reschedule of one calendar on its own (only a revision
               every member accepts); attendance and ending as for any booking

Booking states, including a guest's move waiting for the host and a meeting booking

Any booking leaving pending_host, tentative or confirmed for declined, expired or cancelled frees its time. When one member's meeting booking does, the meeting is off, and the organizer's agent cancels the others (§5.12).

Attendance is declared by the host, never inferred from the clock. Once the appointment's start has passed, the host (staff, or its own system of record) MAY mark a confirmed booking completed or no_show; before the start, both are refused with INVALID_STATE. A booking whose end has passed and that nobody marked stays confirmed. For abuse controls (§7.2), a principal counts as proven once it holds a confirmed or completed booking whose end has passed: an unmarked past appointment is treated as kept, because the host had the chance to mark it a no-show. A booking marked no_show is not kept, so it earns its principal no standing.

Marking a no-show is a guest-visible change: it bumps sequence, issues a new signed receipt and sends booking.no_show. The status is all SchCP keeps: nothing is charged or recorded, and no amount is attached to the booking, its receipt or any later offer (§7.6). An open-ended booking is ended rather than marked attended (§4.2): an endpoint MUST refuse to mark it completed without an end, and its end is then part of the booking.

Mapping to iTIP (RFC 5546): hold ≈ REQUEST with PARTSTAT=TENTATIVE that has a deadline; confirm ≈ REPLY ACCEPTED; cancel ≈ CANCEL; reschedule ≈ REQUEST with SEQUENCE+1; 409 SLOT_UNAVAILABLE + alternatives ≈ COUNTER; proposal-response ≈ REPLY or DECLINECOUNTER.

7. Abuse controls

7.1 Holds are bound to mandates

policies.holds.requires_mandate (default true): no anonymous holds. Every hold counts against the mandate's max_bookings, so a mandate for one booking can have at most max_bookings holds at a time. This stops fan-out from one mandate. It does not stop an operator that mints many principals, because self-certifying keys are free (§7.2). If requires_mandate is false, an anonymous hold belongs to the agent key that placed it. It is claimed by the principal whose mandate that same agent presents at confirmation.

7.2 Caps and rate limits

7.3 Reputation-scaled TTL

The endpoint tracks, per agent key, per principal and per attester, the ratio of holds that expired unconfirmed. TTL shrinks as that ratio grows. An agent that converts some holds and abandons others gets shorter holds. One that abandons nearly all of them gets none.

Counts SHOULD decay. The reference implementation keeps exponentially decayed counts (half-life 7 days) and computes

ratio = expired / max(expired + confirmed + released, min_sample)

once a subject has min_sample lifetime outcomes, mapping it to a TTL cap through tiers (< 0.5 → policy max, < 0.75 → 120 s, < 0.9 → 60 s, otherwise 0). Uniform decay leaves a plain ratio unchanged, so a suspended agent, which cannot earn good outcomes, would stay suspended forever. The min_sample floor in the denominator is what lets time alone restore it. With the defaults, five straight expiries suspend holds. They come back capped at 60 s after about a day, at 120 s after three days and at full length after a week. Each new outcome still counts at full weight, so an agent that relapses is judged mostly on what it just did.

An operator SHOULD keep reputation per subject across its tenants too: an agent that abandons holds at one salon on a platform is the same agent at the next. The reference engine takes a shared reputation store for this.

7.4 No deposits

v0.3 let a host take a deposit for scarce slots, to make abandoned bookings cost something. v0.4 has none (§8): SchCP's abuse controls are the ones in this section, which work without money: bounded holds, caps across identities, reputation, attestations, the walk-in reserve and the standing only a kept appointment earns (§7.2). A host that also wants a deposit, or a fee for a late cancellation or a no-show, takes it itself, outside the protocol (§7.6, §8).

7.5 Attestations

Self-certifying principal keys are free, so a host cannot tell a new guest from a script's thousandth identity. An attester can: it checks something scarce (a phone number, a payment card, an ID document) and signs a statement binding that check to one principal key.

{ "iss": "phonecheck.example", "sub": "urn:schcp:principal:jwk-thumbprint:Qh8…",
  "iat": 1792000000, "exp": 1794592000, "assurance": "unique_phone" }

The attestation is a compact JWS with typ: schcp-attestation+jwt, carried in the root mandate's attestations. The host honours it only if iss is listed in its own trust.attesters, the signature verifies against that attester's keys, sub equals the mandate's principal and iat ≤ now < exp. Anything else is ignored, never rejected: an attestation can only add trust.

An honoured attestation changes one thing. The principal's holds count against max_active_per_attester instead of max_active_unproven. It grants no other privilege. The attester becomes a reputation subject itself (§7.3). If its vouchees keep abandoning holds, its cap reaches zero, and they fall back to the ordinary new-guest pool rather than being locked out.

Each host chooses its own attesters. The protocol names no authority, so no attester can become a required toll booth. A host that trusts none behaves exactly as in v0.1.

7.6 Outcomes are the host's business

SchCP keeps no account of what a late cancellation, a late change or a no-show costs or means. (v0.3 recorded them as outcome records, with the fee terms that priced them; v0.4 removes both.) What the protocol does carry is attendance, as a status:

7.7 People as hosts

A host need not be a business. A person whose own agent keeps their calendar publishes a discovery document with host.kind: "person", usually through a personal-calendar service acting as operator (§2.3), with no domain, phone or URL of their own. Their resources are themself (resource_type: "person") with the hours they are willing to be asked for; their services are kinds of meeting ("Coffee", PT45M; "Playdate", open-ended with set_aside: "PT3H", location: "elsewhere"), usually with no price. A person's calendar SHOULD use host_approval, so that its agent asks the person. Where the person wants some things answered without being asked, it uses rules the person signed (§7.9) rather than instant confirmation, so that the yes is the person's and can be checked. The host configuration names its owner, the person's own principal, who may always book their own time without anyone else's yes. That is how one person's agent puts a meeting in their own calendar (§5.12). Its offers name the owner (host.owner), so a meeting contract can name them.

Everything else applies unchanged: the meeting is held, offered, signed, consented to and confirmed like any booking, and the person's agent answers an approval, declines, counters with another place or time (§5.11), proposes a change or ends an open-ended meeting as a host does. A person host is almost always contacts-only.

7.8 Contacts-only access

policies.access: "contacts" makes a host's time visible and bookable only to the principals on the host's own contact list, and to its owner (§7.7).

7.9 Who says yes: the host, a person, or a rule they signed

Every commitment traces to a person. The guest's person approves what their agent books (§5.3). On the host's side, a business's published terms and instant confirmation are its standing yes. A person's calendar asks the person, or uses a rule the person signed in advance. Hosts SHOULD ask their person, and SHOULD answer by rule only where the person chose the rule.

Rules. A rule is a compact JWS, typ schcp-rule+jwt, signed by the person's own key:

{ "iss": "urn:…:Priya…", "principal_jwk": { … }, "iat": …, "exp": …, "jti": "r-…",
  "cnf": { "jkt": "<thumbprint of the calendar agent's key>" },
  "label": "Weekend playdates with my contacts",
  "services": ["playdate"], "weekdays": ["sat", "sun"], "tz": "America/Toronto" }

Rules are kept by the person's calendar and are not published.

Answering. A booking at a host_approval host that a rule covers MAY be confirmed by the host without asking. A meeting member's calendar answers by rule with an acceptance by its own key that carries the rule (§5.12), so every other calendar can check it. Every booking a host said yes to carries answer:

"answer": { "by": "rule", "rule": "Weekend playdates with my contacts", "at": "2026-10-13T13:00:00Z" }

by is "host", "person" or "rule". A business's own answer, given by whoever answers for it, is by: "host": the business answered, not a private individual. At a person's calendar (§7.7) the person's own answer is by: "person". A rule's is by: "rule", and rule gives the label. A guest's agent SHOULD tell its human which it was ("Priya's calendar said yes by her rule; Priya hasn't seen it yet"). A host may also record a person's own yes to an ordinary booking as an acceptance (§5.12) of the booking's offer hash. A guest's move at a host_approval host is answered the same way, and the moved booking's answer says who said yes to it (§5.7).

Rules only ever say yes. A no is the host's own answer, so a declined booking's answer is by: "host", or by: "person" at a person's calendar, including when a member takes back the yes their rule gave (§5.12).

Who says yes: the guest's person, and on the host's side its published terms, its person, or a rule the person signed

Asking for the person. A guest can require that the person answer themselves: answers: "person" in a confirm request, or in a meeting contract. In a meeting it may be asked of some members only: a member's own answers overrides the contract's for them (Priya answers in person, a rule may answer for Ellis), and the organizer's consent text names who answers which way. A host MUST NOT then answer by rule. In a meeting, an acceptance by rule is refused with 400 INVALID_REQUEST, reason person_required.

Owners. The owner booking their own time needs nobody else's yes, so a host_approval calendar confirms its owner's own bookings at once.

8. Money

SchCP arranges time; it never moves money. The protocol carries prices a host chooses to disclose (§5.2), and nothing else about money: no fee for a late cancellation, a change or a no-show, stated or recorded (§7.6).

A host that wants a deposit, prepayment, or a fee for a late cancellation or a no-show sets that policy and collects it itself (§7.6), through its own checkout: on its website, at the counter, or through a link it gives in a refusal's contact remedy or its own messages. SchCP does not carry, link to, vouch for or record that payment, and an agent SHOULD treat any such request as separate from the booking and put it to the human as such. Payment protocols (AP2, ACP, a card network's) can sit beside SchCP; none is inside it.

9. Errors

{ "error": { "code": "HOLD_LIMIT", "reason": "new_guest_pool_full",
    "message": "too many holds from new guests right now; try again shortly",
    "basis": { "policy": "holds.max_active_unproven", "limit": 6 },
    "retry": { "after": "PT60S" },
    "remedies": [ { "type": "attest", "attesters": ["phonecheck.example"] },
                  { "type": "contact", "phone": "+1-416-555-0177", "url": "https://…" } ] } }
HTTP code meaning
400 INVALID_REQUEST Schema violation, including any field a request schema does not define (payment data among them, §8); details lists paths
401 BAD_SIGNATURE Request signature missing, invalid, stale or replayed
401 MANDATE_INVALID Mandate missing, bad signature, expired, wrong audience, wrong agent, revoked
403 MANDATE_SCOPE Action not in scope
403 MANDATE_LIMIT Action would violate a mandate constraint (including a closed mandate used on a different offer)
403 OFFER_APPROVAL_REQUIRED The principal must sign the offer (§5.3); details gives offer_sha256
403 NOT_OWNER The hold or booking belongs to another principal
403 NOT_PERMITTED A contacts-only host, and the principal is not on its list (§7.8)
403 NOT_ELIGIBLE The service is for new or for returning guests only (§5.2)
404 NOT_FOUND
409 SLOT_UNAVAILABLE With alternatives
409 HOLD_EXPIRED
409 STALE_SEQUENCE
409 CONTRACT_MISMATCH An acceptance named a different offer_sha256 (a missing one is 400)
409 INVALID_STATE e.g. cancelling a cancelled booking
409 IDEMPOTENCY_CONFLICT Same key, different body
422 OUTSIDE_BOOKING_WINDOW Min notice / max advance / not yet released / a change for required participants to a start already passed (§5.10)
422 OUT_OF_SERVICE_AREA No place where one is needed, or one outside service_area (§5.2)
422 DATA_NOT_ACCEPTED Free-text notes refused by policies.data (§5.3)
429 RATE_LIMITED / HOLD_LIMIT With Retry-After
501 UNSUPPORTED e.g. required participants at a host without policies.tentative

There are no payment errors: nothing in SchCP can be declined for money.

9.1 Idempotency

Every mutating request carries idempotency_key. One route is exempt because it needs no key: DELETE /holds/{id} is idempotent by nature (releasing a released hold returns it unchanged). The endpoint stores (agent keyid, idempotency_key) → (request hash, principal, response) for at least 24 h. A retry with the same body returns the stored response. A retry with a different body returns IDEMPOTENCY_CONFLICT.

A stored response is the result of acting for one principal, so a replay is authorized like a fresh request: it MUST carry a valid mandate from the same principal whose scope covers the action (and, for booking routes, whose bookings constraint covers the booking). The stored result must also fall within the current mandate as a fresh request's would: its service, time (its start, for an open-ended booking), stated all-in total, and for an acceptance the offer it names. Holding the key is not enough, which matters when one agent key serves many principals.

Refusals of the credential (401 and 403) are not stored, nor are transient failures (429, 5xx). The same key can then be retried with a corrected mandate, such as the closed mandate an OFFER_APPROVAL_REQUIRED asked for. Once an operation has succeeded, a retry under a rotated or re-issued mandate replays the stored result and never repeats the action.

Agents SHOULD take the booking from the signed receipt rather than from the unsigned booking beside it, and SHOULD check that the receipt's offer_sha256 is the one they just accepted (a genuine receipt for a different booking is not a confirmation of this one); the reference SDK does both.

9.2 Refusals: one shape, registered reasons

Every refusal has the same shape: errors, and the refusal on a declined, host-cancelled or lapsed booking. code is the class; reason is a registered value under it that an agent can act on; message is for a person; basis names the rule (a policy and its limit), never anything about other guests; retry.after says when trying again could help; and remedies say what would. Every refusal offers a non-agent way to reach the host, a contact remedy with the phone or url the host publishes in discovery, when it publishes one; reputation and attestation can shut out people without phones, and they need another way in. A person's calendar normally publishes neither (§7.7), and its refusals then carry no contact remedy.

code reasons
INVALID_REQUEST malformed, not_on_grid, unknown_service, resource_not_selectable, meeting_invalid, acceptance_invalid, person_required, window_too_short
BAD_SIGNATURE bad_signature, stale_signature, replayed_nonce
MANDATE_INVALID mandate_invalid, mandate_expired, mandate_revoked, mandate_required
MANDATE_SCOPE scope_missing
MANDATE_LIMIT outside_mandate, mandate_exhausted, shared_limit_reached
OFFER_APPROVAL_REQUIRED closed_approval_required
CONTRACT_MISMATCH contract_mismatch
NOT_ELIGIBLE returning_guests_only, new_guests_only
DATA_NOT_ACCEPTED free_text_notes
OUT_OF_SERVICE_AREA outside_service_area, place_required
NOT_OWNER not_owner
NOT_PERMITTED not_a_contact
NOT_FOUND not_found
SLOT_UNAVAILABLE slot_taken, walk_in_reserve, outside_hours, availability_unknown
HOLD_EXPIRED hold_expired
STALE_SEQUENCE stale_sequence
INVALID_STATE invalid_state, already_started, not_started, awaiting_members
IDEMPOTENCY_CONFLICT idempotency_conflict
OUTSIDE_BOOKING_WINDOW too_soon, too_far_ahead, not_yet_released
RATE_LIMITED rate_limited
HOLD_LIMIT agent_limit, new_guest_pool_full, reputation_suspended, waitlist_limit
UNSUPPORTED unsupported
DECLINED (booking refusal) host_declined:fully_booked, host_declined:service_not_offered, host_declined:needs_consultation, host_declined:other, participant_declined, organizer_withdrew
CANCELLED_BY_HOST (booking refusal) host_cancelled
EXPIRED (booking refusal) host_did_not_answer, participant_did_not_answer, not_reconfirmed, meeting_incomplete

When a request fails several checks at once (a signature both stale and wrong, a mandate both expired and malformed), the host reports any one of the failures; the spec fixes no order between them, and an agent MUST NOT rely on which it gets.

The first reason under a code is its default (the reference implementation's registry is in src/protocol/errors.ts). Remedy types: contact (phone, url), attest (attesters the host trusts), approve_offer (offer_sha256), retry (after).

10. Adapters

The host endpoint is a façade over the host's system of record:

11. Out of scope for v0.4