API keys and webhooks
Questi contenuti non sono ancora disponibili nella tua lingua.
How an integration authenticates to GuestCraft with a workspace API key or an organization token, hands a front end a client session, receives signed notifications of reservation, Channex import, folio, rate and housekeeping changes, and reads the events of the last 30 days (ADR 0040, ADR 0047). API revision 3.9.1 and later; rotation, allow-lists, the event catalog, the events API, replay and redelivery since 5.3.0; organization tokens and client sessions since 6.2.0. AI agents use the same credentials through the MCP server.
API keys
Section titled “API keys”Create a key
Section titled “Create a key”A member with api_keys.manage (built-in: administrators) and unrestricted property access opens
Settings → API keys, names the key, ticks its permissions, chooses all or some properties, an
expiry and optionally the client addresses it may be used from, and copies the secret. It is shown
once: GuestCraft stores only its SHA-256.
gc_live_…in a production workspace,gc_test_…in a sandbox workspace.- The form offers only permissions the creator holds. Team, activity, keys, webhooks, Channex administration and the organization’s exchange rates are browser-only and never go to a key.
- The screen reads the properties only when a key is limited to some of them. A creator who may not read properties can still create keys for every property, list and revoke them.
- A key belongs to one workspace. On every request it acts with its permissions and properties
narrowed to what its creator still holds there: a creator who loses a permission takes it from
the key at once, and a creator who leaves the workspace leaves the key answering 401. The list
says so for each key (
creator_access:full,partialornone): rotate such a key to make yourself its creator, or create a replacement before you remove a person.
Limit a key to some client addresses
Section titled “Limit a key to some client addresses”An allow-list holds up to 20 IPv4 or IPv6 addresses or CIDR ranges (203.0.113.0/24,
2001:db8::/32, a single address becomes a /32 or /128); a range must not have host bits set after
its prefix. A request from another address, or one whose address the instance does not know,
answers 401 auth.unauthenticated and is audited as api_key.denied with the reason ip_address
(at most once a minute per key). The client address comes from the trusted proxy configuration
(GUESTCRAFT_CLIENT_IP_HEADERS and GUESTCRAFT_TRUSTED_PROXIES, or Vercel’s own header): an
instance without it knows no address, so a key with an allow-list answers 401 there. An IPv6 address
is compared with all its 128 bits: 2001:db8:1234:5678::9/128 admits that address only.
Each request is judged by its own address, and by the instant it arrived against the key’s expiry,
also one that arrives while another request of the key is looked up. A refused request never
affects the allowed client: it costs one of its own address’s 30 failed requests a minute, and
neither the key’s budget nor its place on the instance. Once the instance has seen the key, a
request from outside the list is refused before the key is looked up; after 30 refusals in a minute
its address gets 429 auth.rate_limited.
Rotate a key
Section titled “Rotate a key”Rotate issues a replacement with the same name, permissions, properties, allowed addresses and
expiry, shows its secret once, and keeps the old key working for a grace period of 0 to 168 hours
(24 by default, never past its own expiry). Deploy the new secret within the grace period; the old
key then answers 401. You become the replacement’s creator, so you need every permission of the key
(403 auth.forbidden otherwise). Only an active key that no rotation has replaced can be rotated
(409 api_key.not_rotatable). The list shows the old key as replaced, with the end of its grace
period as its expiry.
The same operations exist in the API, for a browser session only (an API key or a device token answers 401):
| Operation | |
|---|---|
GET /api/v1/api_keys |
keys of the workspace, 100 per page, with grantable_permissions ({ code, group }) |
POST /api/v1/api_keys |
{ name, permissions, property_ids?, expires_at?, allowed_ips? } → 201 { token, api_key } |
GET /api/v1/api_keys/{id} |
the key, never its secret, with ETag |
POST /api/v1/api_keys/{id}/rotate |
If-Match, { grace_period_hours? } → 201 { token, api_key, previous_api_key } |
POST /api/v1/api_keys/{id}/revoke |
If-Match → 200, the revoked key |
Call the API with a key
Section titled “Call the API with a key”GET /api/v1/propertiesAuthorization: Bearer gc_live_…import { createGuestCraft } from "@guestcraft/sdk";
const guestcraft = createGuestCraft({ baseUrl: "https://hotel.example.com", apiKey: process.env.GUESTCRAFT_API_KEY,});- Without
GuestCraft-Workspace-Id, the request works in the key’s workspace; another workspace answers403 auth.forbidden. - A revoked or expired key, one whose creator left, or one used from an address outside its
allow-list answers
401 auth.unauthenticated. - An operation the key may not perform answers
403 auth.forbidden, as for a member. - Over 600 requests a minute with one key on one instance answer
429 auth.rate_limitedwithRetry-After: 1, whatever the operation,GET /api/v1/workspacesincluded. - An instance counts a key’s requests while it remembers the key, in one of 10,000 places, at most 1,000 of one organization (its workspaces’ keys and client sessions and its organization tokens together) and 100 of one workspace. A key that authenticates while every place of its workspace, of its organization or of the instance holds a key used within the last minute gets the same 429 until a place frees; keep fewer than 100 keys of one workspace, and 1,000 credentials of one organization, in use within any minute. Creating more workspaces does not give an organization more places.
- A key that an instance does not remember as authenticated is a guess there: its first request
after the instance starts, after a day unused, or once the instance has given its place to
another key (the least recently used of its workspace, else of its organization, else of the
instance, once that share is full, going first once unused for a minute; a key used within the
minute never gives its place, and a refused key changes no budget). A client address has one guess looked up at a
time, and an instance as many as half the connections of its auth pool. Any other guess gets the
same 429 at once, before the key is looked up; it is not queued, so retry after
Retry-After. - Requests with the same key that arrive while its guess is looked up, from any address, are not guesses: they wait for that lookup and get its answer, each within the key’s own budget once it authenticated. Up to 100 requests wait for one lookup, besides the request being looked up; more get the 429.
- While a flood of guesses from many addresses keeps the instance’s permits busy, a key that is not remembered gets in only when a retry finds a permit free, which is rare under a large flood; after a restart, that applies to every key. Remembered keys and sessions are not affected.
- A client address whose guesses failed 30 times in a minute (an unknown, revoked or expired key, a creator without access, or a key refused for want of a place) gets the 429 for its guesses until its budget refills (one request every two seconds).
- An IPv6 address counts as its /64 (Better Auth reduces it to that), and the /64s of one /48 also share 300 failed guesses a minute (five a second refill them). Neighbours within a /48, such as other customers of your ISP, other networks of your site or other devices of a mobile carrier, can therefore spend the budget that your new keys’ first requests need; your remembered keys are not affected.
- The mobile app’s device tokens share these budgets (API revision 3.9.1): an instance’s failed device-token guesses and failed key guesses from one address count together. So do the exchanges of the app’s one-time sign-in and pairing codes (API revision 4.6.0).
- A remembered key is never refused because others’ keys failed or are being looked up, from its
address (a shared egress or NAT) or any other. A burst of requests with one new key, or after a
restart, is answered: its first request is looked up and the others wait for it. An integration
platform that sends many customers’ keys from one egress address warms them up one per round
after a restart, since different keys do not share a lookup: 40 keys take about 40 rounds, 40
seconds with
Retry-After: 1. - When the instance cannot look a key up in time (a database lock held for maintenance, no
database connection free within 10 seconds, a connection lost), the request answers
503 server.unavailablewithRetry-After: 2; it changed nothing, so send it again after that delay. - Configure the trusted client-address headers of your proxy: without them every guess shares the instance’s permits, with no failure budget per client.
- With several instances (Vercel), each keeps its own counts and remembers keys on its own.
- A request with a key is never authenticated by a cookie, and the cookie origin policy does not apply to it.
Creation (api_key.created), rotation (api_key.rotated, and api_key.created for the
replacement with rotated_from), revocation (api_key.revoked) and refused uses
(api_key.denied, with the reason revoked, expired, creator_access, ip_address,
workspace or forbidden, at most once a minute per key and instance) are audited, never with the
secret or the key’s name. Actions taken with a key are audited with the actor type api_key: the
entry names the key (api_key_id) and its creator (actor_id), on whose delegation it acts, and
Settings → Activity shows “API key apk_… (created by usr_…)”. The access log line of each request
carries api_key_id. Every persisted write a key can make is audited: a Messages thread’s read
marker, which members move unaudited as the team’s working state, is audited when a key moves it
(channex.message_thread_read, the thread’s identifier only).
Organization tokens
Section titled “Organization tokens”An organization token (gc_org_…) serves central automation of a group: one credential for
several workspaces of the organization, such as a data warehouse, or for the organization itself,
such as the provisioning of a chain’s workspaces. It follows the model of API keys: its secret is
shown once, it can expire, be limited to client addresses, be rotated with a grace period and be
revoked, and it never holds more than its creator still holds.
Create a token
Section titled “Create a token”- Sign in as an owner or administrator of the organization and open Settings → Organization tokens.
- Choose Create token: a name, the permissions in its workspaces (the same list as for API
keys: team, audit, keys, tokens, webhooks, Channex administration and exchange rates stay
browser-only), its
organization permissions (below), all of the organization’s workspaces (those created later
included) or some of them, an optional expiry within two years and an optional list of allowed
client addresses. A token needs one permission of either kind; one without permissions in
workspaces reaches no workspace (
"workspace_ids": []in the API). - Copy the secret now: it is shown once. Lost, rotate or revoke the token.
The list shows each token’s last four characters, permissions, organization permissions,
workspaces, creator, last use and whether its creator still holds all of it (creator_access:
partial once the creator lost a workspace, a permission or, no longer an owner or administrator,
the organization permissions). A workspace administrator who does
not administer the organization neither sees nor manages tokens. Rotation (POST /api/v1/organization_tokens/{id}/rotate) and revocation work as for keys, with the token’s ETag.
Call the API with a token
Section titled “Call the API with a token”import { createGuestCraft } from "@guestcraft/sdk";
const rome = createGuestCraft({ baseUrl: "https://hotel.example.com", organizationToken: process.env.GUESTCRAFT_ORGANIZATION_TOKEN, workspaceId: "ws_01k8b4c7k9f5qr7v1d2s3x4y5z",});- Send
GuestCraft-Workspace-Idwith each request: a token that reaches several workspaces and names none answers403 auth.forbidden, and so does a workspace it does not reach; both are audited (organization_token.denied, reasonworkspace).GET /api/v1/workspaceslists the workspaces it reaches, with its permissions in each. - In each workspace the token holds its permissions that its creator still holds there, on the
creator’s properties. A creator who reaches none of its workspaces any more, a revoked or expired
token, and an address outside its allow-list answer
401 auth.unauthenticated. - The budgets of API keys apply: 600 requests a minute per token and instance; an instance remembers at most 100 tokens of one organization within a minute.
- The budgets of the organization apply too: its tokens, and its workspaces’ keys and client sessions, share 1,000 places of an instance.
- Creation, rotation and revocation are audited in the organization (
organization_token.created,.rotated,.revoked); actions taken with a token are audited with the actor typeorganization_token, naming the token and its creator, and Settings → Activity shows them. The organization-level entries are listed in the organization’s Activity (organizations.audit).
Organization permissions
Section titled “Organization permissions”A token may hold permissions on the organization itself, apart from those it holds in workspaces:
| Organization permission | Operation | Call |
|---|---|---|
workspaces.create |
workspaces.create |
POST /api/v1/organizations/{organization_id}/workspaces |
organization_audit.view |
organizations.audit |
GET /api/v1/organizations/{organization_id}/audit |
import { createGuestCraftTransport } from "@guestcraft/sdk";import { workspacesCreate } from "@guestcraft/sdk/operations/workspaces";
// No workspaceId: these operations need none.const group = createGuestCraftTransport({ baseUrl: "https://hotel.example.com", organizationToken: process.env.GUESTCRAFT_ORGANIZATION_TOKEN,});const workspace = await group.send(workspacesCreate, { path: { organization_id: "org_01k8b4c7k9f5qr7v1d2s3x4y5z" }, body: { name: "Harbour Hotel", environment: "production" },});- Call them without
GuestCraft-Workspace-Id: a token holding only organization permissions reaches no workspace, and these two operations need none. - The token holds them while its creator owns or administers the organization, checked on every
request. An owner or administrator demoted to member takes them from the token at once: a token
that holds nothing else answers
401 auth.unauthenticated, another403 auth.forbidden. - A token without the permission an operation needs, or called for another organization, answers
403 auth.forbidden; both refusals are audited (organization_token.denied, reasonforbiddenorcreator_access) in the organization, without a workspace. - A workspace the token creates is audited (
workspace.created) with the actor typeorganization_token, naming the token and its creator, who becomes the workspace’s administrator. A token whoseworkspace_idsis null reaches it at once. - Neither permission administers the organization: a token never lists, creates, rotates or
revokes tokens, manages members or roles, renames, archives or restores workspaces, or creates
organizations. Those operations answer
401to it, as to every integration credential.
Client sessions
Section titled “Client sessions”A client session lets a front end, such as a guest’s page, a booking widget or an embedded panel, call the API directly, as Seam’s client session tokens do. Your server mints it with its API key or organization token and hands the token to the front end; the front end needs no other credential, and the session holds only a narrow grant for a short time.
Mint a session on your server
Section titled “Mint a session on your server”The API key or organization token needs client_sessions.manage (create the key with it) and the
permissions it gives the session.
const { data: issued } = await guestcraft.clientSessions.create({ body: { user_identifier: "guest-account-1042", permissions: ["properties.read", "reservations.read"], property_ids: ["prop_01k8b4c7k9f5qr7v1d2s3x4y5z"], expires_at: null, // one hour; at most 24 hours },});// issued.token is gc_cst_…: send it to the front end, never log it.user_identifieris your own identifier of the person using the front end: 1 to 128 letters, digits and._:-. It cannot be an e-mail address or a name; never put personal data there.property_idsis required. Alone, it gives the session 1 to 100 properties of the workspace; an empty list, a repeated property or one of another workspace answers422 client_session.invalidwithfield: "property_ids", and a request without it400 request.invalid, which names the missing/property_ids. Permissions that act on the whole workspace (events, Channex health, administration) are refused to the session, as to a member limited to some properties; a permission the minting credential does not hold answers403 auth.forbidden.scope, optional, limits the session further, to one object (see below). With it,property_idsrepeats the properties the scope names, as the session’s answers list them.- A browser session, a device token or a client session cannot mint sessions.
Limit a session to one reservation, one guest or a public booking
Section titled “Limit a session to one reservation, one guest or a public booking”A page for one guest should reach that guest’s data only, not every booking of a property. The
scope of a session says what it reaches:
scope |
Reaches | Use it for |
|---|---|---|
{ "kind": "properties", "property_ids": [...] } |
every operation of its permissions on these properties (the same as property_ids) |
an embedded staff panel |
{ "kind": "reservation", "property_id": "prop_…", "reservation_id": "rsv_…" } |
GET the reservation, its prepayments, its folio and folios (/folios/{folio_id} of that reservation only), the cancellation simulation and POST …/cancel |
a guest’s “manage my booking” page |
{ "kind": "guest", "guest_id": "gst_…" } |
GET and PUT the guest, GET their reservations, their consents (GET, …/current, POST) |
a guest’s profile page |
{ "kind": "property_public", "property_id": "prop_…" } |
GET the property’s availability, POST quotes and POST reservations for a guest recorded with the booking (an object, never an existing guest’s gst_… identifier) |
a booking widget |
const { data: issued } = await guestcraft.clientSessions.create({ body: { user_identifier: "booking-4711", permissions: ["reservations.read", "reservations.cancel", "policies.simulate_cancellation"], property_ids: ["prop_01k8b4c7k9f5qr7v1d2s3x4y5z"], scope: { kind: "reservation", property_id: "prop_01k8b4c7k9f5qr7v1d2s3x4y5z", reservation_id: "rsv_01k8b4c7k9f5qr7v1d2s3x4y5z", }, },});-
property_idsmust name what the scope names: the same properties as apropertiesscope (in any order),[property_id]for areservationorproperty_publicscope, and[]for aguestscope. Any other list answers422 client_session.invalidwithfield: "property_ids", and mints nothing. -
What a scope names must exist in the workspace: the reservation at the property named with it, a guest that no merge absorbed. Otherwise minting answers
422 client_session.invalidwithfield: "scope". -
An operation is open only when its path names the scope’s own objects: the reservation’s property and reservation, the guest, or the public booking’s property. Every other operation, including every list, search and export, another reservation of the same property and
/guests/{id}of a reservation’s guest, answers403 auth.forbiddenand is audited asclient_session.deniedwith the reasonforbidden.GET /api/v1/workspacesstays open, so the front end can read its workspace and permissions. -
A narrower scope holds only the permissions that its operations check, never the overrides they also accept; any other answers
422 client_session.invalidwithfield: "permissions", and a session uses only these on every request, whatever it was stored with:scopekindPermissions it can hold Never, for example reservationreservations.read,policies.prepayments,policies.simulate_cancellation,reservations.cancel,folios.read(its folios and money members)policies.override_cancellation: the guest cannot set their own feeguestguests.read,guests.manage,guests.consents,reservations.read,folios.read(their reservations’ totals)guests.merge,reservations.cancelproperty_publicreservations.read(availability),rates.read(quotes),reservations.create,folios.read(the amounts of the booking it answers)reservations.override_price,inventory.configure: a visitor neither prices by hand nor books before the business date -
The session’s permissions apply within its scope: give it only what its page does, such as
reservations.read. A session of a reservation or a public booking is limited to that property; a guest’s session reaches the guest in every property its minter reaches. -
A guest’s session stops working (
401) once a merge absorbs the guest: it never follows the merge to the surviving profile. A session ends with the reservation, guest or property it names when that is deleted. A cancelled reservation keeps its session, which still reads it; cancelling it again answersreservation.invalid_state. -
Settings → Client sessions shows each session’s scope.
Use it in the front end
Section titled “Use it in the front end”import { createGuestCraft } from "@guestcraft/sdk";
const guestcraft = createGuestCraft({ baseUrl: "https://hotel.example.com", clientSessionToken: tokenFromYourServer,});const { data: workspaces } = await guestcraft.workspaces.list(); // its permissions and properties- Browsers may call from any origin. The API answers the CORS preflight of every
/api/v1operation withAccess-Control-Allow-Origin: *and never allows credentials: a page can read the answer of a request with a client session’s token only, never one authenticated by a cookie (that answer carries noAccess-Control-Allow-Origin), and a request that asks to send cookies with a session’s token fails its preflight. - On every request the session holds its permissions and scope that its minting credential may
still use. It answers
401 auth.unauthenticatedonce it expires or is revoked, once its minting key or token is revoked or expires (a rotated key’s sessions end with its grace period), and once that credential’s creator holds none of it. Ask your server for a new session then. - An instance remembers at most 100 sessions of one workspace used within a minute, apart from the
workspace’s API keys; more answer
429 auth.rate_limiteduntil places free. - Actions taken with a session are audited with the actor type
client_session; minting and revocation are audited asclient_session.createdandclient_session.revoked(with the user identifier, never the token), refused uses asclient_session.denied.
List and revoke sessions
Section titled “List and revoke sessions”Members, API keys and organization tokens with client_sessions.manage list the workspace’s
sessions (GET /api/v1/client_sessions, also ?user_identifier=), read one and revoke it with
its ETag (POST /api/v1/client_sessions/{id}/revoke). Each shows its scope, which says what
it reaches, and property_ids, the properties that scope names: those of a properties scope,
the one property of a reservation or property_public scope, and none ([]) for a guest’s
session, which names a guest rather than properties. Settings → Client sessions shows the same
list with a revoke action. Sessions are deleted a week after they expire.
GuestCraft’s own guest pages
Section titled “GuestCraft’s own guest pages”GuestCraft’s own guest-facing pages (online check-in, the booking engine) use first-party guest
sessions, not client sessions. They are not API credentials: every /api/v1 operation answers
401 to their gc_gst_… tokens, and no endpoint mints them. The server issues one through an
internal capability (HttpRuntime.guestSessions), bound to one workspace, for one reservation,
one guest or one property’s public booking, for at most an hour, with permissions of the guest
vocabulary that its scope allows:
| Scope | Permissions |
|---|---|
reservation |
booking.read, checkin.read, checkin.submit, payments.request |
guest |
booking.read, checkin.read, checkin.submit |
property_public |
booking.quote, booking.create |
The trust boundary is the page’s: the issuer checks only that what the scope names exists in the
workspace. The page proves the guest’s right to the scope first, by verifying a single-use link
code or a rate-limited lookup, and never accepts an identifier alone as proof. On each request it
verifies the token (verify), which gives the workspace, scope, permissions and expiry, or a
refusal once the session expired, was revoked (revoke, as signing out does), its guest was
merged or its workspace archived. Issues and revocations are audited as guest_session.issued
and guest_session.revoked, by the system.
Webhooks
Section titled “Webhooks”Register an endpoint
Section titled “Register an endpoint”A member with webhooks.manage (built-in: administrators) and unrestricted property access
opens Settings → Webhooks, enters an https:// address on the public internet, chooses its
events (grouped by module, each in one version, see Event versions) and copies
the signing secret (whsec_…), shown once. Then the endpoint can be edited (address, description,
events), disabled and enabled, tested, rotated and deleted, and the 50 most recent deliveries are
listed with their origin, status, attempts and last result. A change refused because someone changed the
endpoint first reloads it: review it and act again.
An endpoint has at most one test event waiting: sending another while one waits (for example
after a failed attempt) makes the waiting one due now. A disabled endpoint refuses tests with
409 webhook_endpoint.disabled.
The event catalog (EVENT_CATALOG in core/integrations; GET /api/v1/event_types lists its
versions). data.object says what data.id names:
| Event type | When | data.object |
|---|---|---|
reservation.created@1 |
a reservation is created | reservation |
reservation.updated@1 |
its guest, source, notes or confirmation change | reservation |
reservation.cancelled@1 |
it is cancelled | reservation |
reservation.rooms_cancelled@1 |
some of its rooms are cancelled | reservation |
reservation.checked_in@1 |
a room checks in | reservation |
reservation.checked_out@1 |
a room checks out | reservation |
reservation.check_in_undone@1 |
a check-in is undone the same day | reservation |
reservation.check_out_undone@1 |
a check-out is undone the same day (a closed folio reopens) | reservation |
reservation.overbooked@1 |
an imported booking leaves a room type overbooked | reservation |
reservation.ota_reported@1 |
the OTA received a no-show or invalid-card report, or a cancellation over an invalid card, as Channex confirmed or an operator recorded from the channel’s extranet (booking reports) | reservation |
channex.revision_applied@1 |
a Channex booking revision was imported | channex_revision (with reservation_id) |
channex.revision_quarantined@1 |
a Channex booking revision needs review | channex_revision |
folio.extra_posted@1 |
an extra is posted to a folio | folio_movement |
folio.payment_posted@1 |
a payment is recorded | folio_movement |
folio.refund_posted@1 |
a refund is recorded | folio_movement |
folio.reversal_posted@1 |
a movement is reversed | folio_movement |
folio.room_posted@1 |
room nights are charged (desk, night audit or import) | reservation |
folio.policy_posted@1 |
a cancellation or no-show fee is posted | reservation |
folio.rerated@1 |
a folio is re-rated | reservation |
folio.closed@1 |
a folio closes: a member closes it, or the reservation’s last room checks out (settled or with an approved balance) and closes every folio still open, each with its own event | folio |
folio.reopened@1 |
a closed folio reopens: a member reopens it, or the undo of the last room’s check-out reopens each folio that check-out closed, each with its own event | folio |
rate_plan.created@1, rate_plan.updated@1 |
a rate plan is created or changed | rate_plan |
rates.changed@1 |
nightly prices or restrictions change | rate_calendar (id is the property) |
rates.restored@1 |
a rate change is undone | rate_calendar |
unit.housekeeping_changed@1 |
a room’s housekeeping status changes | unit |
housekeeping_task.created@1 |
a housekeeping task is created: generated from the stays or made by a person, a clean, a ticket or a failed checklist item | housekeeping_task |
housekeeping_task.assigned@1 |
its assignee changes: by a person, in bulk, by automatic assignment or its Undo; also a task created for a member, after its created |
housekeeping_task |
housekeeping_task.status_changed@1 |
its status changes, also a cancellation or reinstatement that follows its stay | housekeeping_task |
webhook.test@1 |
a member with webhooks.manage sends a test event |
webhook_endpoint |
Folio, rate, room and housekeeping task events also carry property_id, and folio events, an
applied revision and the tasks a stay made reservation_id, so that a receiver can read the object
again through routes nested under them.
Event versions
Section titled “Event versions”Each event type is a version of a family: reservation.created@1 is version 1 of
reservation.created. Every type above is version 1 today; a later version of a family, when one
ships, renders the same committed event with another data, from the identifiers the event
recorded (never from the object’s current state, and never inventing what the event did not
record). The envelope (id, type, created_at, workspace_id) is the same in every version.
Each version of a family renders the events recorded as any other, so an endpoint receives the
family’s events whichever version GuestCraft records them as: when GuestCraft starts recording
@2, an endpoint that receives @1 keeps receiving every event, and one that receives @2 also
receives, in @2, the events recorded as @1 before, including replays of them.
event_typesnames families. An endpoint’sevent_typeslists the families it receives, each by its first version, such asreservation.created@1: the same values, with the same meaning, as before any family had a second version. It is required on registration, replaces the families on a change, and accepts each family once, by that name only; a later version’s type in it answers422 webhook_endpoint.invalidwithfield: event_typesandreason: later_version.- A family’s version is pinned when the endpoint subscribes to it: the family’s current
version at that moment, its latest, which is never deprecated. A later version of the family
never moves the endpoint. A change that keeps a family keeps its version; a family dropped and
named again is a new subscription.
event_versionsreports the version of every family ofevent_types, one entry each, such as{"reservation.created": 1}. event_versionschooses another version, on registration or on a change, for some of the endpoint’s families, afterevent_typesapplies (in the same request). A family the endpoint does not receive, a version the catalog does not have, or a deprecated version for a family that does not already receive it answer422 webhook_endpoint.invalidwithfield: event_versionsandreasonnot_subscribed,unknown_versionordeprecated_version. A client that sends onlyevent_typesnever needs it.- A version is current, or deprecated with the last day it stays supported. GuestCraft supports
a deprecated version for at least six months after its successor ships. An endpoint that
receives a deprecated version keeps receiving it and keeps it through any edit that keeps its
family; no endpoint may newly choose it. The endpoint lists its deprecated versions in
deprecated_event_types, each withsupported_untiland thesuccessorto choose inevent_versions, and Settings → Webhooks shows them. Nothing retires a version automatically: retiring one after its date is a separate, announced decision. - Saved choices are never changed for you. A version is never silently replaced with another.
A saved version the catalog no longer has stays in
event_versions, receives nothing, and is replaced by choosing a version of its family (Settings → Webhooks lists it). - A delivery keeps its version. The body is fixed when the delivery is queued: its retries, and a redelivery of it, send the same version and bytes even after the endpoint chose another version. A replay renders each event in the version the endpoint receives when the replay starts, exactly as the event’s own delivery would.
- One event, one identity.
idandwebhook-idare the event’s identifier in every version: an endpoint receives an event in one version only, so deduplicate by it as before.
Settings → Webhooks offers a choice of version only for a family that has several; registering
or editing there sends event_types, and event_versions only for such a family.
| Operation | |
|---|---|
GET /api/v1/event_types |
every version: type, family, version, object, state (current or deprecated), supported_until, successor; public, no credential |
Replay and redeliver
Section titled “Replay and redeliver”- Redeliver a delivery from the list: a new delivery of the same event, with the same version,
body and
webhook-id, due at once and retried like any other. While the endpoint has a pending delivery of that event,409 webhook_delivery.pending. - Replay events of a time range within the last 30 days, ending now or earlier, for all the
endpoint’s families or some of them (
event_types, named as the endpoint names them): every event of the range is queued again as a new delivery, in the version the endpoint receives the family in when the replay starts, except an event the endpoint has pending. A family the endpoint does not receive, or receives in a version the catalog no longer has, cannot be replayed (422 webhook_replay.invalid,field: event_types). A range of more than 1,000 events is refused (422 webhook_replay.invalid,reason: too_many_events): replay it in parts. Replays are audited aswebhook_endpoint.replayedwith the range, types and counts; redeliveries aswebhook_delivery.redelivered. - A disabled endpoint refuses both with
409 webhook_endpoint.disabled.
| Operation | |
|---|---|
POST /api/v1/webhook_endpoints/{id}/deliveries/{delivery_id}/redeliver |
→ 202, the new delivery (origin: redelivery) |
POST /api/v1/webhook_endpoints/{id}/replays |
{ from, until, event_types? } → 202 { events, queued } |
Addresses that name or resolve to a private, loopback, link-local, unique-local, multicast or
reserved range are refused, single-label names and localhost too. For IPv6 only the global
unicast space (2000::/3) is public; an embedded IPv4 address (IPv4-mapped, NAT64 64:ff9b::/96,
6to4) is checked as itself, and the local-use NAT64 prefix 64:ff9b:1::/48 is refused. Every
address a name resolves to is checked when the connection opens, and a proxy configured in the
environment (HTTPS_PROXY with NODE_USE_ENV_PROXY) is never used for webhooks. Any port is
allowed. In development only, GUESTCRAFT_WEBHOOKS_ALLOW_PRIVATE=true lets private destinations
through, with plain http:.
What a receiver gets
Section titled “What a receiver gets”POST /your/endpointcontent-type: application/jsonwebhook-id: evt_01k8…webhook-timestamp: 1793350800webhook-signature: v1,K6P…=
{"id":"evt_01k8…","type":"reservation.checked_in@1","created_at":"2026-10-30T09:12:00Z", "workspace_id":"ws_01k8…","data":{"object":"reservation","id":"rsv_01k8…", "property_id":"prop_01k8…","room_id":"rrm_01k8…"}}The body names what changed and never carries guest data: read the reservation with an API key,
at GET /api/v1/properties/{property_id}/reservations/{id}. Delivery is at least once: the
webhook-id is the event’s identifier, the same in every attempt, so skip one you have processed.
Events of one endpoint can arrive out of order.
Answer 2xx within 10 seconds: the attempt ends then, whatever has arrived. Any other answer (including a protocol switch or a status outside 100–599), a redirect, a timeout or a failed connection is retried after 1 and 5 minutes, 30 minutes, 2, 6, 12, 24 and 24 hours (nine attempts, about 69 hours). An endpoint that fails every attempt for that long is disabled; enabling it again resumes its pending deliveries. Deliveries are kept 30 days.
A preview deployment, or a worker over a copy of the database restored for another instance, never sends webhooks, not even for delivery jobs queued before.
Delivery is shared between workspaces and between the endpoints of one workspace:
- One delivery job per workspace. A workspace has at most one delivery job running and one waiting, whatever the number of workers; work that falls due during a run is taken by the waiting job or by the next minute’s dispatch. Each worker runs at most four jobs at once, a job sends for up to about 70 seconds, and the dispatcher names at most 1,000 workspaces a minute, those whose work has waited longest first.
- Turns within a run. A run moves up to 1,000 new events into deliveries, then sends batches of ten that take each due endpoint’s oldest delivery before any endpoint’s second, and at most five of one endpoint: a backlog of one endpoint never fills a batch while another waits.
- Backoff of a failing endpoint. After a batch in which every attempt to an endpoint failed, none
of its deliveries is attempted before
backoff_until: as long as it has failed so far, from 30 seconds to an hour, so each failed probe about doubles the wait. A success, a test event or enabling the endpoint again ends the wait. The backoff is stored with the endpoint, so every worker respects it, and it is separate from each delivery’s own retry schedule above, which it never resets.
Verify the signature
Section titled “Verify the signature”webhook-signature holds v1, and the base64 HMAC-SHA256 of
{webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded secret after whsec_
(Standard Webhooks). After a rotation, for 24 hours, it holds
two signatures separated by a space, one per secret. Verify before you parse:
import { WebhookVerificationError, webhooks } from "@guestcraft/sdk";
const event = await webhooks.verify(rawBody, request.headers, process.env.GUESTCRAFT_WEBHOOK_SECRET);verify refuses a missing header, a timestamp more than five minutes away and a body no signature
matches, comparing in constant time. Any Standard Webhooks library verifies the same requests.
Events API
Section titled “Events API”Members and API keys holding events.read (built-in: administrators; refused to anyone limited to
some properties, since events name objects of every property) read the catalog’s events of the
last 30 days, newest first, in Settings → Events or through the API. Each event is returned in
the type it was recorded with, its domain event’s own version, and the type filter matches that
type; a webhook delivers the same event, with the same id, in the version its endpoint chose. An integration uses it to catch up after a pause or a disabled endpoint. An event never
changes: its identifiers are fixed when it is written, so the events API, the webhook of its own
version and a replay in that version send the same body, and ETag (the event’s identifier) is a
strong validator. Events recorded before
API revision 5.3.0 are completed with the identifiers their object names when read.
| Operation | |
|---|---|
GET /api/v1/events?type=…&from=…&until=…&cursor=… |
100 events per page, next_cursor; type takes comma-separated catalog types |
GET /api/v1/events/{id} |
one event; 404 event.not_found after 30 days or outside the catalog |
A filter outside the catalog, or a range that is empty once limited to the last 30 days, answers
422 event.invalid with the field.
Limits
Section titled “Limits”Organization permissions beyond creating workspaces and reading organization Activity are
planned; events older than 30 days are not kept. Third-party applications connect with
OAuth. No event family has a second version yet, and retiring a
deprecated version is not automated. The maintenance re-encryption of ENCRYPTION_KEYS
(credentials:reencrypt) rewrites the current and previous
signing secret of every endpoint, so an old key version can be removed after it succeeds.