#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.
- An amount has no more decimals than its currency's minor unit (ISO
4217: 2 for CAD, 0 for JPY, 3 for BHD and KWD);
79.001 CADand125.0 JPYare invalid. It may have fewer (79is79.00 CAD), and the canonical form, which hosts SHOULD send, has exactly the minor unit's decimals. - Arithmetic is exact, in integer minor units. A percentage (a tax rule's
percent, a surcharge's) is a JSON number used as the exact decimal it is written as (12.5is 125/1000, never a binary float), applied to an amount in minor units and rounded half up to a whole minor unit: 13 % of 79.00 CAD is 1027 minor units, 10.27. - Amounts in different currencies are never added or compared. A total
in another currency than a mandate's
max_pricedoes not fit it (MANDATE_LIMIT), and an offer whose lines are not all in the total's currency is invalid.
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:
- 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. - 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 theadagents.jsonmove: both sides have to claim the relationship. - 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):
Covered components:
"@method" "@target-uri" "content-digest" "schcp-mandate"(content-digestomitted for bodiless requests,schcp-mandateomitted when no mandate is sent), plus thecreated,keyid,nonceandalg="ed25519"parameters. Covering the mandate header means a mandate cannot be swapped under a valid signature. A request whose body is empty (zero bytes) is bodiless.Discovery and operator documents are fetched with a plain, unsigned
GET; every request to a host endpoint is signed.One exact form. RFC 9421 leaves the label and the order of components and parameters to the signer; this profile fixes them, so every implementation produces the same bytes. The request carries exactly one signature, labelled
schcp:Signature-Input: schcp=("@method" "@target-uri" "content-digest" "schcp-mandate");created=1792000000;keyid="Z5hJ…";nonce="q3Vh…";alg="ed25519" Signature: schcp=:<base64 Ed25519 signature>:Components appear in that order (leaving out those that do not apply), then the parameters in the order
created,keyid,nonce,alg, with no others. The signature base is RFC 9421's: one line per component,"@method": POST(upper case),"@target-uri": <URL>,"content-digest": <header value>,"schcp-mandate": <header value>, then"@signature-params":followed by theSignature-Inputvalue afterschcp=, lines joined by a single\nwith none at the end. The host MUST refuse any other label, a second signature, another order, another parameter set or anotheralg(401 BAD_SIGNATURE).nonceis at least 16 random bytes, base64url without padding, fresh for every request. Hosts treat it as an opaque string.@target-uriis the public URL the agent addressed. An endpoint behind a proxy or a platform router MUST reconstruct it from its configured public origin, never from theHostheader.keyidis the RFC 7638 JWK thumbprint of the agent's public key. The agent sends the public key in theSchCP-Agent-Keyheader on each request: the base64url (no padding) of the UTF-8 JSON of an Ed25519 JWK withkty,crvandx, optionallykid, and no other member (a privated, above all, is refused), in any order. The host computes the thumbprint itself, so member order does not matter;keyidMUST equal it. (Registering a key once withPOST /agentsis out of scope for this version.)Content-Digestusessha-256(RFC 9530) over the body's exact bytes, and the host recomputes it.The host MUST reject a signature whose
createdis more than 300 s from its clock (reasonstale_signature). It MUST remember each(keyid, nonce)untilcreated + 301 s, the last moment the signature could still verify, and reject any reuse (reasonreplayed_nonce). A nonce is remembered only once its signature has verified, so a forged request cannot spend someone else's nonce. Every other signature failure has reasonbad_signature.
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:
- The signature verifies against
principal_jwk, andissis 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. audequals 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).iat ≤ now + 60 sandnow < exp. Verifiers MUST allow 60 seconds of clock skew oniat, and no more, so independent implementations accept and refuse the same mandates.expgets no skew. A passedexpis reasonmandate_expired, so the agent knows to ask for a fresh mandate; every other verification failure ismandate_invalid.cnf.jktequals the thumbprint of the key that signed the HTTP request. A stolen mandate is useless without the agent's key.- The action is in
scope. - The action satisfies
constraints: service, slot insidewindow(start ≥ not_beforeandend ≤ not_after, both inclusive; for an open-ended booking, its start for both; §4.2), the offer's stated all-intotal≤max_price(an offer that states no price always fits), and active bookings made under thisjti<max_bookings(across every host it names, for a shared booking limit; below). - The mandate is not revoked. The host MAY consult
status. Revocation is best-effort, so shortexpvalues 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:
Before confirming, the endpoint claims one booking for each link with a shared booking limit:
POST {status}/claimswith{ "claim_id": "{host id}/{hold id}" }. The key app answers201(claimed),200(that claim id is already held: a confirm retried under another idempotency key) or409(no bookings left).A refusal, a link without a
statusURL, or a key app that does not answer refuses the confirm with403 MANDATE_LIMIT, reasonshared_limit_reached. A booking the key app cannot vouch for is not made.If the confirm then fails, the endpoint gives back only a claim it made in this call (
DELETE {status}/claims/{claim_id}). A200claim belongs to whichever call made it.GET {status}reportsremainingbeside the revocation status.A booking that will not happen gives its unit back: when it is cancelled (by the guest or the host), declined, or expires, the endpoint sends
DELETE {status}/claims/{claim_id}for each claim it holds, once. A guest's cancel gives the unit back before the endpoint answers, so the agent can book again at once. A key app that does not answer never stops the cancel: the endpoint SHOULD retry for a day and then leave the claim held, so the principal has one booking fewer left, never one more. A booking that went ahead (attended, ended or marked a no-show) keeps its unit.
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:
- it is signed with the agent's key, the one the parent's
cnf.jktnames, and carries that public key asissuer_jwk(an Ed25519 JWK) instead ofprincipal_jwk; - it carries the parent's compact JWS, exactly, as
parent; issandsubare the parent's: the principal does not change;cnf.jktnames the sub-agent's key (it may be the same agent's);attestationsdescribe the principal key, so a host reads them from the root only; a child need not carry any.
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:
- the root is signed by
principal_jwkand itsissis that key's thumbprint (check 1), and every child is signed by itsissuer_jwk, whose RFC 7638 thumbprint equals its parent'scnf.jkt; - every link passes checks 2 and 3 (audience and time) and the schema;
- the parent's scope includes
delegate, the child's scope is a subset of the parent's, itsissandsubequal the parent's, and itsexpis no later than the parent's; - no two links share a
jti(below); - the leaf's
cnf.jktis the key that signed the request (check 4).
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.
- What is set aside. The host still has to plan, so an open-ended
service declares
set_aside: an ISO 8601 duration (a playdate sets asidePT3H), or"until_close", the default, meaning until the first of the booking's resources closes for the day (a drop-off holds its bay until the shop closes). The set-aside time covers any buffer (buffer_afteris not added). Nothing else is booked on top of it.set_asideon a service with a fixed duration is a configuration error. - No
endon the wire. Slots, holds, offers and bookings for an open-ended service carry"open_end": trueand noend; an offer'sslotis{ "start" }only. The calendar copy (event) still needs a length, so itsdurationis the time set aside, and its title and location are as for any booking. - The human commits to a start. A mandate's
windowbounds only the start of an open-ended booking (§3.3 step 6), and availability only needs the start to fall inside the requested window. Consent text says "from 8:00 a.m., open-ended" instead of a range. - Ending it. The host ends an open-ended booking when it has ended,
with an end time that is after the start and not in the future. The end
frees whatever was set aside beyond it, makes the booking
completedwith thatend, bumpssequence, and sendsbooking.endedwith a signed receipt carrying the end. A booking the host never ends keeps its set-aside time and is treated as kept once that has passed (§6). An open-ended booking has no end to extend (§5.8); the host may still propose another start, which sets aside time afresh.
#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" }
- The all-in total, when there is a price.
price,totalandlinesappear only when the host states a price; an offer without them has nothing about money in its terms, and a mandate'smax_pricehas nothing to bind. They come together: an offer withpricehastotalandlines, one withtotalhaslines, andlinesalone is invalid. (An extension of an unpriced booking that adds an amount, §5.8, hastotalandlinesbut noprice: the amount is its only line.) The lines' amounts sum exactly tototal, all in its currency (§2.1); an agent MUST refuse an offer whose lines do not. Where they appear,lineslists everything the host says the person will pay it if things go as planned: the service, mandatory fees (service.fees), any surcharge, and tax on the taxable lines (taxrules in discovery;service.tax_exemptexempts a service).totalis their sum, andmax_pricebinds to it. The price is disclosure: the person pays the host directly, outside SchCP (§8). A host that states fees, surcharges or a per-person price MUST state a price. - No fee terms. An offer MUST NOT state a fee or a penalty for a
cancellation, a change or a no-show, and its
linesandtotalMUST NOT include one. Cancelling and changing through SchCP are always free (§5.6, §5.7). A host's own policy for late cancellations and no-shows is its business, outside SchCP (§7.6). - Claims are closed. An offer's claims MUST match the
OfferClaimsschema, which allows no member it does not define, at any depth: thehost,service,slotandreconfirmobjects and every price line are closed too, so acancellation_feeinsideslotfails like one at the top. An agent MUST verify that before it shows the offer to anyone, and MUST refuse an offer that fails, even one the host's key signed: a term SchCP does not define (a cancellation fee, say) is never put before the human. Mandates (§3.3), meeting contracts, acceptances and rules (§5.12) are closed the same way, and so are receipts and the booking they carry (§5.3). retention(required) is how long the host keeps the booking's records after it ends or is cancelled:policies.retentionwhen the offer is made (§2.1). It is part of the contract, so the consent text states it ("Maple Street Auto keeps my booking details for 1 year after the booking"), and a change offer repeats the booking's own retention, not the host's current policy.reconfirm, when the host asks guests to reconfirm, states that requirement (§5.10).resourcenames a resource the guest chose, andresource_namewhat people call it, so the consent text says who ("Haircut with Marcus at Fades on Queen");placeis where it happens when not at the host's;open_endmarks an open-ended booking (§4.2);party_sizeechoes the party;approval_byis the latest time ahost_approvalhost will answer.
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:
- Access. At a contacts-only host, a principal not on its list is
refused first:
403 NOT_PERMITTED, reasonnot_a_contact(§7.8). - Eligibility.
eligibility: {guest: "returning"}(a recall visit) admits only principals who have kept an appointment here (§7.2);"new"(a new-patient offer) only those who have not. Otherwise403 NOT_ELIGIBLE, reasonreturning_guests_onlyornew_guests_only. - Release schedules.
booking_window.opens: {days_ahead: 30, at: "10:00"}releases a whole day at once, at 10:00 local time 30 days before it, the way restaurants and popular barbers release their tables and chairs. It replacesmax_advance, whose rolling window would open evening slots hours after the declared drop. Before the release:422 OUTSIDE_BOOKING_WINDOW, reasonnot_yet_released, with the schedule inbasis. - Place.
service.locationsays where it happens:"host"(the default: at the host's own premises),"guest"(a plumber, a mobile groomer: at the guest's address) or"elsewhere"(a playdate in a park, a coffee: at a place the guest names). For the last two the hold MUST giveplace({name?, street?, locality?, region?, postal_code?, country?}): at the guest's address it needs at leastpostal_codeandcountry; elsewhere it needs at leastname. When the host publishespolicies.service_area({country, postal_prefixes}), the place MUST have a postal code and country inside it, in either case. Otherwise422 OUT_OF_SERVICE_AREA, reasonplace_requiredoroutside_service_area. Aplacesent for a service at the host's is ignored. The hold, offer, booking and the booking's calendar event carry the place (the event's location is its name and address on one line), so the technician knows where to drive, the other parent knows which park, and the human sees it on the consent screen. - Surcharges.
surcharges: [{when: "same_day"|"weekend"|"after_hours", label, amount|percent, after?}]adds afeeline to the offer when the slot qualifies (after_hoursfromafter, default 17:00 local). A surcharge is part of the all-in total, taxed like the service, and so bound bymax_price. It is a price for the time booked, never a fee for cancelling, changing or missing it.
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:
- SHOULD always show the person the exact consent text, never a summary of it;
- SHOULD point out what differs from what the person asked for: another day or time, another place, another person or resource, a price they did not mention or above their limit; and SHOULD point out how long the host keeps the booking's records;
- MUST NOT say yes on the person's behalf, except under a rule the person signed (§7.9) or limits they signed (an open mandate).
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):
approved:"offer"when the root mandate (the one the person signed) carried that hash,"contract"when the person signed a meeting contract that names it (§5.12),"limits"when an open mandate covered it;mandate: thejtiof that root mandate. Links an agent added to the chain are the agent's own; the mandate that binds the person is theirs. After a change, it is the mandate that accepted the change;mode:"human_present"or"human_not_present", as that mandate'sconsent.modesays, when it says.
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.
- Ask for terms.
POST /bookings/{id}/change-offer{ "idempotency_key", "sequence", "start", "place" }(scopereschedule).startandplaceare 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 returns201with aproposal:{ "proposal_id", "start", "end", "expires_at", "by": "guest", "place", "offer" }(placewhen the change has one; noendfor an open-ended booking, which sets aside time afresh at the new start). The offer haskind: "change", thebooking_id, the booking's terms and total, andprev: 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. - Accept it.
POST /bookings/{id}/reschedule{ "idempotency_key", "sequence", "offer_sha256" }.sequenceMUST equal the booking's current sequence, otherwise409 STALE_SEQUENCE(iTIP's rule: stale updates are ignored). The hash MUST name the open change offer, otherwise409 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. - The host's yes, where it approves each booking. A
host_approvalhost 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; thenanswernames the rule). Accepting then returns200with the booking unchanged: its old time, contract andsequence, and the accepted change offer asproposalwithawaiting_host: trueandconsent, how the guest agreed to the move (§5.3), which the receipt carries once the move is made. The new time stays held untilexpires_at, the host'sapproval_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,answerrecords who said yes, and the guest getsbooking.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 getsbooking.change_declinedwith arefusal: reasonhost_declined:*, orhost_did_not_answerunderEXPIRED. The booking also records it asdeclined_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.
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.
- Joining needs a mandate that could hold any slot in the window
(scope
hold; the window inside the mandate's; the all-in total withinmax_price), from an agent and principal whose holds are not suspended (§7.3). The window is at most 31 days, and for a fixed-length service at least as long as the service: a shorter one could never be offered anything, and is refused with400 INVALID_REQUEST, reasonwindow_too_short. A waiting entry counts against the mandate'smax_bookings, and an agent may have at mostmax_entries_per_agententries (429 HOLD_LIMIT, reasonwaitlist_limit). At a contacts-only host only its contacts may join (§7.8). For a service at the guest's address or elsewhere, the entry carries aplace, checked as a hold's is (§5.2), and the offered hold carries it. Reading an entry is checked against the mandate's constraints; leaving one needs scopehold, as releasing a hold does. - A freed slot goes to the waitlist first. When a booking ends early (a guest's cancellation, a lapsed approval, a reschedule away from it, or a participant's decline), the endpoint offers the time, in the same transaction and before it is visible to anyone else, to the first waiting entry it suits: the whole appointment (for an open-ended service, its start) inside the entry's window, the mandate still valid and covering it. Order is first come, first served. A host cancellation or decline releases the time to the waitlist only when the host says the time is still good (the bay that broke is not offered to the next guest).
- A waitlist offer is a hold, with every control a hold has (§7): the
entry's agent must be under
max_active_per_agent, a newcomer's pool must have room, and the TTL isoffer_ttlscaled down by reputation (none at all for a suspended agent or principal). An entry that fails any of these is passed over. The offer must sit on at least one of the resources that came free, so an entry for another service waits rather than taking an unrelated slot. The endpoint places the hold for that entry's principal, signs its offer, and sendswaitlist.offeredwith theholdand the entry. - The agent confirms the hold like any other, naming its offer's hash
(§5.3). An agent whose mandate covers the offer MAY confirm without
asking the human; an offer that needs the principal's signature
(
closed_mandate, §5.3) is taken to the human first. - A lapse moves down the line. An offered hold that expires counts
against the entry's agent and principal like any expired hold (§7.3),
the entry becomes
lapsed, and the slot goes to the next entry. Leaving the waitlist (DELETE) while holding an offer releases it to the next entry the same way. A freed time is offered at most three times; after the third lapse it is public, so a line of throwaway entries cannot keep it off the market.
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).
- A required participant (one with
who, a principal id) must accept the booking with their own key app's mandate. Until every required participant has, the booking istentative(202); it occupies its time but is not final. Each one has aconfirm_bydeadline: now plus the shorter ofconfirm_withinand the host'spolicies.tentative.max_confirm_within, or the booking's start if that comes first. A tentative booking is therefore always settled by its start. At the deadlinedefaultapplies ("release"unless given). A host withoutpolicies.tentativerefuses required participants (501 UNSUPPORTED). At a host that approves bookings itself, the booking ispending_hostfirst; participants may answer meanwhile, and approval makes itconfirmedif every required participant has accepted,tentativeotherwise. - A named-only participant (no
who) has nothing to answer: an attendee who is not the signer, such as a child booked by a parent. The booking and its receipt name them separately from the signer (status: "not_required"). - Answering.
POST /bookings/{id}/respond { "idempotency_key", "accept", "offer_sha256" }, signed by the participant's agent under the participant's own mandate (any scope; its constraints still apply, includingmax_priceagainst a stated total and a closed mandate'soffer_sha256). A participant may read the booking withGET /bookings/{id}; the response carries the booking's contract asoffer, so they can verify the host's signature and hash it. The hash MUST be the booking's currentoffer_sha256(409 CONTRACT_MISMATCHotherwise). Accepting from the last required participant makes the bookingconfirmed(sequence + 1). Declining makes itdeclinedwith refusal reasonparticipant_declined: the time comes free (waitlist first), and nothing is recorded. The booker hears every answer asbooking.response, and every status change as usual. - Locks. A tentative booking counts against the booker's agent cap
and the new-guest pool (§7.2) like a hold, and earns the booker no
reputation credit until it is final. A decline counts like a released
hold; a lapse by
default: "release"(expired, reasonparticipant_did_not_answer) counts like an expired hold (§7.3). A booker vouched for by an attester (§7.5) keeps drawing on that attester's pool while the booking is tentative, and the attester shares the outcome. - Privacy. A participant who declined can no longer read the booking (§5.5).
- Changes re-ask. A participant accepted one contract. Accepting a
change offer for a booking with required participants returns it to
tentative, with each of thempendingagain and a fresh deadline, computed as above from the new start. A change to a start that has already passed would leave them no time to answer: asking for it or accepting it is refused with422 OUTSIDE_BOOKING_WINDOW, reasontoo_soon.
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.
- The counter is a host change offer (§5.7, §5.8): a proposal with
by: "host"and the newstart(andend), withplacewhen it names one. The new terms are in the signed offer. It holds the new time until it lapses, at most until the booking's approval deadline. The guest hears it asbooking.change_proposed. The booking stayspending_host. - Accepting it (
POST /bookings/{id}/proposal-response,accept: trueand the change offer's hash, §5.8) makes the bookingconfirmed, ortentativeif required participants must answer again (§5.10). The counter was the host's yes, so the booking'sansweris the host's own:by: "host"at a business,by: "person"at a person's calendar (§7.9). The booking adopts the offer's time and place. Where §5.3 says so, the principal's own signature is needed, as for any change offer. - Declining it (
accept: false) clears the counter; the booking keeps waiting for the host's answer to the original terms. - A meeting booking (§5.12) is not countered this way. A member's
counter attaches to their booking. The organizer takes it into a
revised contract that every member answers; accepting the change offer
alone is refused with
400 INVALID_REQUEST, reasonmeeting_invalid.
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:
- reads availability at each calendar and keeps the starts all of them offer;
- holds that time at each, with the
placewhere the meeting is elsewhere; - writes the meeting contract below over the holds' signed offers;
- has the organizer's human approve its text once (the key app's signature over the contract is that approval);
- confirms each hold with the contract;
- 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.
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." }
- Members. There are at least two members: distinct principals,
each with their own calendar, and the organizer (
iss) is one of them. Each member'soffer_sha256is the SHA-256 of that calendar's own signed offer for the time, so the contract covers every member's terms. A person's calendar names its owner's principal in its offers (host.owner). Offers only reach contacts, so this tells the organizer nothing they did not know. - Businesses. A business that advertises
meetings(§2.1) may be a member. Its entry hasbusiness: trueand the principalurn:schcp:host:<host id>. It has no person to accept: its signed offer is its yes. The organizer is always a person, and at least one other member is a person; an organizer and a business alone are an ordinary booking. Where the business's service happens on its own premises, its offer names no place: the contract's place is the business, and its calendar does not compare it. - Terms.
start, thenendoropen_end: true(exactly one), andplace.attendeesare people who come along and answer nothing (the children); the text a member is shown MUST name them, and the text the organizer approves MUST say whether a rule may answer for the others.expis the deadline for everyone's answer. answers."person"means each member's own person must accept, and no rule may answer for them (§7.9). The default is"person_or_rule". A member's ownanswersoverrides it for that member, so an organizer can ask one person to answer themselves and let a rule answer for another; the consent text then names each group ("Priya Shah answers in person; Ellis Moreau may answer by a rule they signed").statementis the text the organizer approved. Like any consent text (§5.3), it is generated from the claims.revisionstarts at 1.- Hash. The contract's hash,
contract_sha256, is the base64url SHA-256 of the compact JWS, exactly as for offers. It is what every member accepts, what every member's booking names (meeting) and what every receipt carries.
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:
- The signature verifies with
principal_jwk,issis that key's principal, and the contract has the shape above (400 INVALID_REQUEST, reasonmeeting_invalid). issis the booker, the mandate's principal, andexphas not passed (meeting_invalid).- This host takes part in meetings (it advertises
meetings), and exactly one member names it. On a person's calendar that member's principal is the owner; on a business's, the member is markedbusiness: trueand named by the host principal. Theirservice_idis this hold's service (meeting_invalid). - That member's
offer_sha256is this hold's offer (409 CONTRACT_MISMATCH, with this offer's hash indetails). - The contract's
start,endoropen_end, andplaceare this offer's (409 CONTRACT_MISMATCH). - No required participant (§5.10) is named. In a meeting the members are
the people who answer, and anyone else is an attendee
(
meeting_invalid).
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:
- the signature verifies with
jwk; issis the member's principal;terms_sha256is the contract's current hash;iatis no earlier than the terms (the contract'siat; for an ordinary booking, when it was made) and no later than now, each within 60 seconds (terms − 60 ≤ iat ≤ now + 60), so a rule is judged when it was really used and cannot be revived by backdating;byis one of the two below.
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:
- names
jwkas the agent key that may answer (cnf.jktis its RFC 7638 thumbprint); - was in force at the acceptance's
iat:rule.iat − 60 ≤ iat < rule.exp(the rule'siatgets the same 60 seconds as a mandate's, §3.3; itsexpnone); - covers the member's service, the start's weekday in the rule's zone, and the organizer.
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:
- A different hash is
409 CONTRACT_MISMATCH, with the current one indetails. - An acceptance from someone who is not such a member, two from one
member, or an invalid one is
400 INVALID_REQUEST(acceptance_invalid, orperson_required). - A member missing is
409 INVALID_STATE, reasonawaiting_members, withdetails.awaitingnaming them. - A booking still
pending_hostmay be committed when the acceptances include its own member's; that acceptance is then the member's answer. A business's booking stillpending_hostcannot be: the business has not said yes (409 INVALID_STATE,awaiting_members).
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:
- 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 stillpending_hostortentative. - New contract. It writes the contract again, with the same
meeting_id, a higherrevision, and each member'soffer_sha256set 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. - Hand it over. At each calendar,
POST /bookings/{id}/meeting{ "idempotency_key", "sequence", "contract" }(scopereschedule).
The endpoint MUST check:
- the booking is a meeting booking,
pending_hostortentative; sequenceis current;- the contract verifies;
- it has the same
meeting_idand the same organizer, who is the booker, and a higherrevision(meeting_invalid); - this calendar's member is the same person, with the same service;
- the member's
offer_sha256names the booking's current offer or its open change offer (409 CONTRACT_MISMATCH); - that offer's time and place are the contract's (
CONTRACT_MISMATCH).
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:
- obtains a change offer at each calendar where the terms change
(
POST /bookings/{id}/change-offeris 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); - 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; - 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
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
max_active_per_agentcaps concurrent holds per agent key.max_active_unprovencaps the holds held at once, across all identities, by principals that have not yet kept an appointment at this host. This is the sybil bound: a script that mints a thousand identities can occupy at most this many slots, while returning guests are unaffected. A kept appointment is a booking whose time has passed without it being cancelled or marked a no-show (§6). Making a booking does not count, because a booking is free to make and free to cancel (§5.6): if it counted, a script could book and cancel to launder each throwaway identity into a returning guest. A no-show earns nothing either. This is why a host can still mark attendance although SchCP records no outcomes (§7.6). Once earned, the standing is a lifetime fact and does not decay.max_active_per_attesterdoes the same for new guests vouched for by one trusted attester (§7.5). They draw on that attester's pool instead of the unproven one, so a flood of throwaway identities cannot crowd them out.walk_in_reserve: {resource_type, keep, days?}keepskeepresources of that type out of agents' reach on the listed days (all days if none): holds, tentative bookings and confirmed agent bookings together may occupy at most the type's count minuskeepat any moment. Phone, web and walk-in bookings are unaffected, so the barber's regulars and the person at the door still get a chair on Saturday. Refusals are409 SLOT_UNAVAILABLE, reasonwalk_in_reserve, and such times are not offered to agents (§5.1).- Releasing a hold in the last fifth of its TTL counts as an expiry for reputation (§7.3). Otherwise "release just before it lapses" would be free squatting.
- Availability queries are rate-limited per agent key (token bucket).
Exceeding the limit returns
429withRetry-After. Endpoints SHOULD also rate-limit by network origin before verifying signatures, and MUST bound the work a single query can cause (the reference endpoint refuses more than 3,000 candidate starts per query). - Operators enforce across tenants. An operator serving many hosts sees one agent key at all of them. It SHOULD keep one rate allowance per agent key across its tenants, not one per host, so an agent cannot multiply its allowance by the size of the platform.
#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:
- Attendance is declared, as a status only. Once a booking's start has
passed, the host MAY mark it
completedorno_show(§6). The status, the bumpedsequence, the new receipt and thebooking.no_showwebhook are all SchCP carries. An endpoint MUST NOT attach a record or an amount for a no-show to the booking, its receipt or any later offer. Its one effect inside SchCP is on standing (below). - Nothing is priced or carried. Offers, bookings and receipts state no cancellation tiers, late-change treatment or no-show fee (§5.2), and a mandate has no ceiling for one (§3.3). Cancelling and changing through SchCP are always free and record nothing (§5.6, §5.7), and so is a lapse for a missed reconfirmation (§5.10).
- A host keeps its own policy and records. A host MAY have a
late-cancellation or no-show policy of its own, keep its own records of
what its guests did, and act on them in its own systems: billing a
no-show through its own checkout (§8), declining the next booking at a
host_approvalhost, or calling the guest. None of that passes through SchCP, and SchCP neither vouches for it nor enforces it. An agent SHOULD put any such request from the host to the human as separate from the booking. - What a no-show does inside SchCP is withhold standing. A booking
marked
no_showis not a kept appointment, so it neither makes its principal proven for the newcomer caps (§7.2) nor counts towardeligibility: {guest: "returning"}(§5.2). That standing is keyed to the principal key; a new key starts unproven again.
#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).
- The list is private to the host. It MUST NOT be published, in discovery or anywhere else in the protocol; discovery says only that access is by contacts.
- Availability, holds, the waitlist and confirm each check the requesting
principal, the root principal of the presented mandate. Availability,
public elsewhere, then needs a mandate to show who is asking. A request
with no mandate, or from a principal not on the list, is refused with
403 NOT_PERMITTED, reasonnot_a_contact, andbasis: { "policy": "access", "access": "contacts" }. - Membership is checked when the request is made, so a contact removed between a hold and its confirm cannot confirm.
- The refusal reveals that the host exists and is contacts-only, which discovery already says; it reveals nothing about the host's time.
#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" }
cnf.jktnames the one agent key that may answer by the rule.labelis the rule in the person's own words, shown wherever it is used.services,weekdays(read intz, which is then required) andorganizers(principals) narrow what it covers; an absent field does not narrow it.- A rule covers a booking or meeting when it was unexpired at the time it was used and every field it states matches.
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).
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).
- No message carries a payment instrument, an authorization, a capture,
a refund or a balance. Request schemas are closed, so an endpoint MUST
reject a request that carries payment data (a
paymentmember, for instance) with400 INVALID_REQUEST, and MUST NOT accept, store or act on payment data received any other way. - Nothing in a booking's life depends on money having moved: no hold waits for an authorization, no confirm captures, no cancellation refunds, no no-show forfeits.
- A stated price is what the host says the guest will pay it, directly.
The consent text says so, and a mandate's
max_priceis a ceiling on what the human agrees to be told, not authority to pay.
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:
- Native: the endpoint is the system of record (the reference implementation's default).
- Calendar fallback: availability = working hours minus the busy
times in the host's calendar (CalDAV/Google/Graph free-busy).
The calendar knows nothing about resources, so the host declares
capacity (e.g. "2 bays") and the adapter cannot see walk-ins until
they are written down. That is why bookings default to
confirmation: "host_approval". A booking writes a TENTATIVE event and the host approves it from a link (SMS or email). A person's calendar (§7.7) is often kept this way too, with their agent reading it. Every later transition (approval, decline, expiry, cancellation) rewrites the event, and a write that fails is retried at the next sync, so a lapsed booking never lingers as busy time. Recurring events are expanded with their exceptions: a cancelled occurrence is free, and a moved one is busy only at its new time, including an occurrence from beyond the window that was moved into it. Expansion is bounded; a calendar that cannot be fully expanded within the bound is an incomplete sync, and the adapter keeps the busy time it had rather than reading the shortfall as free time. For the same reason a calendar host offers nothing until its calendar has been read once, and nothing past the horizon it was read for (31 days in the reference adapter): an unread calendar is not a free one. Such times are refused with409 SLOT_UNAVAILABLE, reasonavailability_unknown, and are not offered. A sync that shows a confirmed booking's time busy displaces it (§5.8). - Platform connector: wraps a booking platform's API (Square, Mindbody, …) using the host's OAuth grant. Most platforms have no hold primitive, so the adapter emulates holds locally. The tradeoffs are discussed in the whitepaper.
#11. Out of scope for v0.4
- Payment of any kind, by design (§8).
- Late-cancellation and no-show policies, by design. They are the host's own, kept and enforced outside SchCP; the protocol carries only attendance as a status, which is what earns standing (§7.2, §7.6).
- Matching staff to services, and travel time, on purpose. Which
person at a host can do a service ("Sam doesn't do beard trims") is the
host's own matching. A guest sees only when a service is available, and
choosing a particular person is already covered by selectable
resources (§5.2). How people get to a meeting is theirs to work out; the
booking is for the time and the place. A fixed
buffer_afteris all the protocol carries. - Arrival windows: bookings are for exact starts (won't do).
- Entitlements and sales, on purpose. SchCP arranges when and where people meet. Whether a guest holds a class pass or a membership, and how a host shares out scarce times (a lottery for a release, say), are the host's own business, like payment (§8).
- Searching across a brand's locations, on purpose. A brand claim only makes the relationship checkable (§2.4); which location to try is the guest's and their agent's choice.
- Recurring meetings, not in v0.4. Each occurrence is its own meeting, arranged on its own; an assistant can arrange the next one.
- Finding a place, on purpose. The guest names it and the other side may counter (§5.11); choosing where to meet is the people's own business, like getting there.
- Group bookings beyond one party at one host, except as a meeting (§5.12).
- A reputation shared across hosts, on purpose. Signed receipts could feed one, but it would let hosts link a person across places; each host keeps its own view (§7.2, §7.3).
- Structured accessibility requests (step-free access, a quiet space, extra time) that shape allocation, appear in the signed offer and never raise the price: an open question. (Records retention is decided: each host states its own, §2.1; receipts carry what they need as evidence, §5.3.)
- An independent implementation: so far this specification has been implemented, and its conformance runner exercised, only by its authors.