Salta ai contenuti

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.

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, partial or none): rotate such a key to make yourself its creator, or create a replacement before you remove a person.

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 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
GET /api/v1/properties
Authorization: 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 answers 403 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_limited with Retry-After: 1, whatever the operation, GET /api/v1/workspaces included.
  • 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.unavailable with Retry-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).

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.

  1. Sign in as an owner or administrator of the organization and open Settings → Organization tokens.
  2. 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).
  3. 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.

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-Id with each request: a token that reaches several workspaces and names none answers 403 auth.forbidden, and so does a workspace it does not reach; both are audited (organization_token.denied, reason workspace). GET /api/v1/workspaces lists 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 type organization_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).

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, another 403 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, reason forbidden or creator_access) in the organization, without a workspace.
  • A workspace the token creates is audited (workspace.created) with the actor type organization_token, naming the token and its creator, who becomes the workspace’s administrator. A token whose workspace_ids is 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 401 to it, as to every integration credential.

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.

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_identifier is 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_ids is required. Alone, it gives the session 1 to 100 properties of the workspace; an empty list, a repeated property or one of another workspace answers 422 client_session.invalid with field: "property_ids", and a request without it 400 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 answers 403 auth.forbidden.
  • scope, optional, limits the session further, to one object (see below). With it, property_ids repeats 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_ids must name what the scope names: the same properties as a properties scope (in any order), [property_id] for a reservation or property_public scope, and [] for a guest scope. Any other list answers 422 client_session.invalid with field: "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.invalid with field: "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, answers 403 auth.forbidden and is audited as client_session.denied with the reason forbidden. GET /api/v1/workspaces stays 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.invalid with field: "permissions", and a session uses only these on every request, whatever it was stored with:

    scope kind Permissions it can hold Never, for example
    reservation reservations.read, policies.prepayments, policies.simulate_cancellation, reservations.cancel, folios.read (its folios and money members) policies.override_cancellation: the guest cannot set their own fee
    guest guests.read, guests.manage, guests.consents, reservations.read, folios.read (their reservations’ totals) guests.merge, reservations.cancel
    property_public reservations.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 answers reservation.invalid_state.

  • Settings → Client sessions shows each session’s scope.

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/v1 operation with Access-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 no Access-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.unauthenticated once 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_limited until places free.
  • Actions taken with a session are audited with the actor type client_session; minting and revocation are audited as client_session.created and client_session.revoked (with the user identifier, never the token), refused uses as client_session.denied.

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

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.

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_types names families. An endpoint’s event_types lists the families it receives, each by its first version, such as reservation.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 answers 422 webhook_endpoint.invalid with field: event_types and reason: 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_versions reports the version of every family of event_types, one entry each, such as {"reservation.created": 1}.
  • event_versions chooses another version, on registration or on a change, for some of the endpoint’s families, after event_types applies (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 answer 422 webhook_endpoint.invalid with field: event_versions and reason not_subscribed, unknown_version or deprecated_version. A client that sends only event_types never 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 with supported_until and the successor to choose in event_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. id and webhook-id are 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
  • 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 as webhook_endpoint.replayed with the range, types and counts; redeliveries as webhook_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:.

POST /your/endpoint
content-type: application/json
webhook-id: evt_01k8…
webhook-timestamp: 1793350800
webhook-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.

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.

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.

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.