Channex guest messaging
Questi contenuti non sono ancora disponibili nella tua lingua.
GuestCraft exposes encrypted guest conversations through workspace and property inboxes. Channex charges extra for the Messages application, per property and by property type; GuestCraft cannot show or cover that price. Activation therefore needs an explicit acknowledgement, and the operator should check the price in the Channex account first.
Operations
Section titled “Operations”| Operation | Resource | Grant |
|---|---|---|
| Messages state of every mapped property | GET /channex/messages |
messages.read |
| Messages state of one property (ETag: its revision) | GET /channex/mappings/{property_id}/messages |
messages.read |
Activate ({active: true, acknowledge_cost: true}) or deactivate ({active: false}), If-Match |
PUT /channex/mappings/{property_id}/messages |
messages.configure; shared-account activation also requires channex.manage_account_mode |
Confirm that an unconfirmed installation is GuestCraft’s ({installation_id}, If-Match, audited) |
POST /channex/mappings/{property_id}/messages/install_attempt/confirm |
messages.configure; on a shared account also channex.manage_account_mode |
Leave an unconfirmed installation alone ({installation_id}, If-Match, audited) |
POST /channex/mappings/{property_id}/messages/install_attempt/forget |
messages.configure |
| Request a synchronization (202) | POST /channex/messages/synchronizations |
messages.configure |
List threads: property_id, filter=all|to_answer|unread|requests, limit, cursor |
GET /message_threads |
messages.read |
| Unread and to-answer counts | GET /message_threads/summary |
messages.read |
| Thread with its latest 1,000 messages, outbox and requests (ETag) | GET /message_threads/{thread_id} |
messages.read |
Older messages, page by page: limit, cursor |
GET /message_threads/{thread_id}/messages |
messages.read |
| Move the shared read marker, If-Match | PUT /message_threads/{thread_id}/read_state |
messages.read |
| Composer variables for a language | GET /message_threads/{thread_id}/variables |
messages.send |
| Queue a reply (202, Location, optional If-Match) | POST /message_threads/{thread_id}/messages |
messages.send |
| Queue a file (202, Location) | POST /message_threads/{thread_id}/attachments |
messages.send |
| Delivery state of a queued item (ETag) | GET /message_threads/{thread_id}/outbox/{outbox_id} |
messages.read |
| Send a definite failure again | POST …/outbox/{outbox_id}/retry |
messages.send |
| Dismiss a failed or uncertain reply (audited) | POST …/outbox/{outbox_id}/dismiss |
messages.send |
| Booking.com “no reply needed” | POST /message_threads/{thread_id}/no_reply_needed |
messages.send |
| Requests, with or without a thread | GET /message_requests |
messages.read |
| One request (ETag) | GET /message_requests/{request_id} |
messages.read |
| Decide a request (If-Match) | POST /message_requests/{request_id}/decision |
messages.send |
| Workspace retention settings (ETag) | GET /message_retention, PUT /message_retention |
messages.read / messages.configure |
| Erase one guest’s message content | DELETE /message_guests/{guest_id}/content |
messages.erase |
Templates of a property, optional language |
GET /properties/{property_id}/message_templates |
messages.read |
| Create (201, Location), read, replace and delete with If-Match | …/message_templates, …/message_templates/{template_id} |
messages.send (writes) |
Thread, request and template grants follow their property scope; PostgreSQL row security is a second boundary. Retention updates and guest erasure are workspace-wide and require an unrestricted principal; a property-scoped member is refused. Erasure is destructive browser administration and is never grantable to API keys (ADR 0041). Of the built-in roles, admin has
all Messages grants and front_desk has read and send; the others have none. Custom
roles can hold any subset within the grantor’s ceiling. A role without properties.read
sees the inbox without property names; reservation details still need reservations.read.
Booking and change requests can be accepted or declined from GuestCraft before their deadline.
Inquiries offer Pre-approve, Send special offer and Let it lapse, with no inquiry Decline.
Offers use the booking currency supplied by Channex, with a localized money field converted to integer
minor units in GuestCraft; the adapter converts back to the upstream integer major-unit amount. Channex requires whole
major units for special offers, so fractional amounts show a field error and cannot be sent. Without that
currency, special offers are unavailable. Let it lapse sends nothing and shows a lapsed outcome after
a known deadline. The documented inquiry payload currently supplies no deadline, so GuestCraft does
not invent a lapse timer: Let it lapse sends nothing and its explanation remains visible; a lapsed
outcome can only be shown when an authoritative deadline or channel outcome is available.
Every transmitted decision requires confirmation with the guest and stay visible; special offers
repeat the formatted amount and currency.
Reservation declines offer a reason and distinct optional guest and Airbnb messages.
The decision is admitted under the property stop barrier and a stop returns 503 messages.replies_in_flight while a decision is in flight. Retry the stop after settlement; the drain retries a refusal-triggered stop. The request enters resolving so a repeat cannot send it twice; an uncertain outcome is
re-read by event ID and settles to the channel result or reopens. A stale ETag, a retired or
stopped property, a resolved request or a passed deadline is refused with a stable problem code.
The simulator follows Channex finality: an extranet decision wins and is audited separately from
the operator’s requested decision. The durable claim records the requested action, money and actor.
If a lost answer is later confirmed to match that claim exactly, reconciliation attributes the
decision to the operator. A differing resolution, or one without a pending claim, is attributed to
the channel. Exhausted read recovery reopens the request with visible unknown-outcome metadata;
check the channel before trying again.
Problems are specific: messages.thread_not_found, messages.not_retryable,
messages.attachment_too_large (413 with max_bytes:
a file is at most 2,000,000 bytes, decoded), messages.not_active, messages.decision_refused (409: the channel definitely refused the attempted decision), messages.retired (read-only
history of an upstream property that is no longer linked), messages.install_pending (503),
messages.install_unconfirmed (409), messages.install_attempt_changed (409),
messages.replies_in_flight (503), messages.variable_missing (with variable),
messages.thread_changed and messages.version_mismatch (both 412 with the current ETag),
among others in the API reference. A decision about a property’s Messages, like its unlink,
waits at most two seconds for each lock another decision of the same property holds, once its
transaction began (PostgreSQL’s lock_timeout; it bounds neither the whole request nor the
wait for a pooled connection): then a request that changed nothing answers
channex.property_busy (503, Retry-After: 2). An activation that already changed something
(it finished an earlier pending stop, ended stale replies, or recorded or advanced its install
attempt) answers messages.activation_incomplete instead (503, Retry-After: 2, with the
current ETag): the Messages card, which reloads, shows what stands, and activating again with
that ETag continues. Activation records its install attempt before it registers the webhook or
installs, so whatever it made at Channex has a durable record that a stop, an unlink or the
operator settles.
The Messages state’s ETag. A property’s Messages state has one revision, its version and
strong ETag. Every change of the activation (activated, stopping, stopped, its installation
removed), every change of the install attempt (begun, its install requested or refused,
installed, unconfirmed, confirmed, ended or forgotten), every stop, and every mapping change
that changes the linked upstream property the state shows (a link, a relink to another
upstream property, a finished unlink) advances it.
The synchronization status reported with it
(health, failure_code, failed_at, last_sync) does not, so a drain never invalidates an
operator’s view. Activation, deactivation, confirm and forget take it in If-Match; a stale one
answers 412 messages.version_mismatch with the current ETag. Confirm and forget also name the
installation decided about in installation_id.
History paging
Section titled “History paging”A thread’s detail holds its latest 1,000 messages, oldest first, and messages_next_cursor:
null when they begin the conversation, otherwise the cursor of the messages before them.
GET /message_threads/{thread_id}/messages?cursor=… answers the page right before that
position, oldest first, as every list does ({ data, next_cursor, has_more }, limit 1–100,
20 by default); its next_cursor continues further back, and null means the page holds the
conversation’s first message. Without a cursor it answers the latest messages.
Messages are ordered by Channex time, then by ingestion sequence for the same time. The cursor
names a message of the thread, and a page holds the messages ordered strictly before it, so
messages that arrive between two pages never shift one: each message is listed once. A message
Channex dates before the position, ingested late, appears on the page where its time puts it.
The cursor is bound to the caller and to the thread: another thread’s cursor, or a forged one,
is refused with cursor.invalid (400).
Content removed by retention or erased on request stays removed on every page: the message is
listed with readable: false, no body and its redaction_reason, never its ciphertext. Paging
only reads; it changes no read marker. In the browser, “Load older messages” above the oldest
message loads 100 at a time, keeps the message being read in place, and offers to try again
after a failure; “This is the start of the conversation.” follows the last page. Messages
that arrive meanwhile still appear at the bottom.
What GuestCraft reads and writes in Channex
Section titled “What GuestCraft reads and writes in Channex”On an account shared with another system, GuestCraft only reads and writes what belongs to a confirmed, linked property with at least one room type mapped and Messages activated:
- Discovery:
GET /live_feedwithfilter[property_id]andfilter[event], one call per kind (message,inquiry,reservation_request,alteration_request) from a stored cursor (filter[inserted_at][gte], ascending). Amessageevent names its thread. - Threads and messages:
GET /message_threads/{id}for a new thread andGET /message_threads/{id}/messagesfor announced threads only, from the newest messages until a page holds a message stored before the read began (messages the read stored itself, shifted onto later pages by new arrivals, never count). A history longer than ten pages keeps its next page, order and that boundary, and continues in follow-up drains until it reaches the boundary or its end. The conversation view opens on the latest 1,000 messages; older ones stay stored and load page by page (see History paging). An unchanged inbox costs four feed calls per property. - Requests: open requests are re-read by ID (
GET /live_feed/{id}) every ten minutes, because a decision taken in the channel changes the existing event. GuestCraft decisions use the same simulator-backed resolve endpoint and theresolvingidempotency state. - Fail closed: any item of another property, or another event kind, in any of these reads
stops that property with the
filter_violationhealth state and stores nothing of the page. - Installations:
GET /applications/installed, the only documented list of installations, is account-wide and takes no parameters. GuestCraft reads it at activation (and to settle a lost install answer or an unfinished activation), keeps the IDs of the property’s ownchannex_messagesitems and drops everything else unread. A property where another system installed Messages is refused (messages.installation_foreign, 409): one property must not have two systems answering its guests. - Writes: the property’s message webhook, the install
(
POST /applications/install), sends to a known thread or booking,no_reply_needed, and on deactivation, a 403 or unlinkDELETE /applications/{id}/uninstallof GuestCraft’s own installation and the removal of GuestCraft’s own webhook. GuestCraft callsPOST /live_feed/{id}/resolveonly while property admission is active. A stop returns 503messages.replies_in_flightwhile an admitted decision is unsettled; the operator must retry. Channex’s and the OTA’s copies of threads and messages, booking revision archives and audit entries are outside GuestCraft’s erasure; the operator must ask Channex and the OTA to remove those copies.
Unlink deactivates Messages in the same transaction, fails queued replies with
property_unlinked (terminal: never retried), uninstalls GuestCraft’s installation, removes
GuestCraft’s property Messages webhook and finishes the Messages stop, and only then removes
GuestCraft’s booking webhooks under the Channex ownership fence (ADR 0028). A cleanup failure
keeps the unlink pending in Channels, with a retry; its webhook removal can be forgotten only
once the Messages stop has finished.
Stopping. GuestCraft operates Messages only on an installation confirmed as its own, and a
stop removes it before it reports success (ADR 0044). Every Channex Messages request is
admitted by a committed check that its activation is still active. Unlink, deactivation and a
403 from Channex stop the same way: they commit a stop revision and stopping, which ends
admission at once, then uninstall GuestCraft’s installation, settle the property’s unfinished
install attempt, remove its webhook, commit stopped and answer success. An activation begun
before the stop rechecks the stop revision before it commits active: it never becomes active
after a stop that reported success, and removes what it installed instead. A
stop returns 503 while admitted decisions are unsettled and must be retried, because Live Feed
resolve is not protected by uninstall. The drain retries a stop triggered by a Channex 403.
Other requests admitted before the stop use the installation guarantee: once it is gone, Channex
refuses the property’s thread reads, text and attachment sends and “no reply needed” (so the
simulator and Channex’s documentation say; the live test must confirm it), so such a request
still on its way reaches nobody; one Channex accepted before is recorded as sent (or
uncertain if its answer was lost), audited and never resent. Live Feed reads are not refused
and reach no guest. Uploads are accepted by the simulator, while Channex’s documentation lists
a 403 for them: GuestCraft relies on neither, because an uploaded file reaches a guest only
through the attachment send. Late answers are discarded. What “no reply needed” does for guests
is not verified. If Channex cannot be
updated, the stop stays pending with nothing read or sent: deactivation answers
messages.cleanup_failed (503) and the Messages state shows stop_pending; unlink answers
channex.unlink_pending (503) and stays in pending_unlinks. A retry finishes it.
Activation and install attempts. Before anything at Channex, GuestCraft commits an install
attempt (install_attempt in the Messages state), preparing: webhook work only. Right before
the paid install it commits, under the property’s lock, that the install may be sent
(pending), only while the link and the activation the operator confirmed still stand. A
preparing attempt requested no install, so whatever the property lists is never offered as
GuestCraft’s: after two minutes the next activation, deactivation or unlink removes GuestCraft’s
webhook and ends it, and a failed removal keeps it visible for the next try. Channex’s answer to
the install names GuestCraft’s installation; nothing else does, because the install takes no
marker and the installed list carries no creation time or creator. So:
- a definite refusal installed nothing: the attempt is
preparingagain while its webhook is removed, and an installation listed afterwards belongs to another system (messages.installation_foreign); - a lost answer with an installation listed makes the attempt
unconfirmed(messages.install_unconfirmed): Messages stays inactive until a member withmessages.configureconfirms the installation as GuestCraft’s or leaves it alone, both audited; - a lost answer with nothing listed keeps it
pending(messages.install_pending): the install may still arrive. After two minutes, the in-flight bound (each write times out after five seconds and is never repeated), the next activation, deactivation or unlink lists again: nothing listed ends the attempt, an installation listed makes itunconfirmed. PostgreSQL’s clock measures the bound, in the transaction that decides.
An installation is never adopted or uninstalled unless Channex’s answer or a member with
messages.configure confirmed it. Unlink and deactivation settle an attempt that is not settled the same way,
whatever the activation’s state: while its webhook or install may still be on its way they
answer 503 (channex.unlink_pending, messages.install_pending); a preparing one loses its
webhook and ends; an installed attempt is uninstalled by its exact ID; an unconfirmed one
waits for that confirmation (unlink 503, deactivation 409 messages.install_unconfirmed). The
Messages card keeps showing the property, also while its unlink waits (linked: false), with
the attempt to settle; for a linked property, a preparing attempt offers “Remove webhook” and
an installed one “Remove installation”, both the deactivation’s stop.
One serialization point. Activation’s intent and its final commit, deactivation, unlink,
confirm, forget, every step of a stop and the in-flight sweeps lock the workspace’s Channex
connection row and then the property’s row before they decide, the order in which unlink, the
booking import and ARI already take them. Unlink therefore either sees an activation’s committed
attempt and waits for it, or the activation sees the ended link and installs nothing. If the
compensating uninstall of a losing activation fails, GuestCraft keeps its installation recorded
as an installed attempt (and an ended link’s unlink pending), so the next stop removes it.
Reactivation. While a reply of an earlier activation is sending, a new installation could
accept it, so activation answers messages.replies_in_flight (503). A reply sending for more
than two minutes by PostgreSQL’s clock is no longer on its way: it becomes uncertain,
audited, never resent, and activation proceeds. “No reply needed” keeps no durable in-flight
record, so it is not part of this barrier (ADR 0044, residual risk).
Threads, requests and replies keep their upstream property: after a relink
to a different one they are read-only history (retired). The other linked properties remain
active. The account-wide installed-applications list is read only at activation and to settle
an installation, as described above.
Delivery and failure isolation
Section titled “Delivery and failure isolation”A drain sends the outbox first, then ingests each property separately. A failing property
records its health (failing, filter_violation, or deactivated as not_active_upstream on
a 403, which stops through the same path as any other stop at the end of the drain, or, if
Channex could not be updated, in a later drain, an activation, a deactivation or an unlink) and
never stops the others or the outbox. A thread Channex refuses keeps its own
sync_failure and backoff. Only failures of Channex or the database are isolated this way.
Anything else is a bug: the drain records it, then throws it to the job runner, which reports it
without its text and stops (ADR 0008). A send that broke this way is uncertain, never sent
again; an upload is failed with internal; a thread or property shows internal. Booking safety drains are enqueued before messages drains, and the
safety job schedules Messages drains and throttled retention. A request-woken drain schedules
retention only, using a durable per-workspace five-minute claim; it does not fan out Messages reads.
Channex documents no idempotency key for sends. Only the remote call is error-handled: once
Channex accepted a reply, GuestCraft commits its Channex ID first and never sends it again,
whatever happens to the local transaction afterwards; the row is uncertain (audited) after two
minutes and becomes sent when the ingested thread contains that ID. An ambiguous answer from Channex is
uncertain at once; a property message with the same text is shown as a hint only. Only a
definite failure can be retried, and only while its activation holds: a definite failure that
arrives after unlink ends the reply as property_unlinked, audited as
channex.message_cancelled. A delivered or dismissed reply keeps no text or file in the
outbox.
Unread compares the order in which messages were ingested with the shared read marker, so a message ingested after a click stays unread. “To answer” compares the latest guest message with the latest property reply by Channex time, or a request still undecided in GuestCraft whose deadline has not passed; system messages never hide a question.
Deployment
Section titled “Deployment”On owning Vercel production, committed activation, webhook and reply jobs wake the shared
worker through Next.js after(); only verified webhooks whose event is a message kind wake
the messages drain. Reads, invalid input and rolled-back transactions do not wake it. Docker
retains the resident worker. The five-minute safety job schedules a drain for every workspace
with active Messages.
Templates are stored per property and language. Composer variables are resolved by the server from the linked reservation, the property and an inquiry’s booking details; dates follow the template language. The check-in time is typed by the operator until properties store one.
Message records use the integration keyring. When rotating ENCRYPTION_KEYS, run
credentials:reencrypt (it rewrites connections, messages, outbox payloads, request details and
templates) and keep the old keys until it reports success. An unreadable record never hides the
rest of the inbox. Workspace administrators configure default, inquiry, reservation and attachment
retention days in Settings → Message retention. Purpose fields left empty use the workspace default;
the default is required and every explicit period must be a whole number from 1 to 3650 days.
Before the first save, the default is the message-content period of the workspace’s
data-protection jurisdiction (its country pack), or 730 days without one
(guest data rights). Erasing a guest from Personal data on the guest
profile also erases this content, with the same redaction: for a guest’s erasure request use that
operation, and this one only to remove conversation content while keeping the rest of the
record (which to use).
Successful saves and erasures are announced. A member with messages.erase can erase from a selected
guest’s profile, or enter an identifier in Message retention, without needing messages.configure.
Erasure requires typing ERASE in a confirmation that explains its scope. Erase-only roles can open
the action without fetching retention settings.
Retention runs as its own bounded channex.messages_retention job, independent of inbox sync. Each
transaction redacts at most 100 message envelopes, titles, request details and outbox payloads in total,
then schedules another batch when full. The five-minute safety scheduler and request-woken discovery
include stopped and disconnected workspaces with stored content, without requiring a Channex key.
Stored attachments use the earlier of their attachment period and conversation-purpose period; the
whole encrypted envelope is redacted together.
Redaction clears ciphertext and records its timestamp and reason, so key rotation skips removed
content. The inbox distinguishes retained or erased content from content whose key is unavailable.
Request details expose details_redaction_reason and details_readable: a false readability flag
means present ciphertext could not be decrypted; absent upstream facts remain distinct from removal.
Identifiers, ordering, erasure records and audits remain. Channex’s and the OTA’s thread/message
copies, booking revision archives and audit entries are outside this operation: the operator must
ask Channex and the OTA to remove their copies. Guest merge integration remains coordinated separately
until guests.merged_into is present on main. Inquiry threads without an established GuestCraft guest
link cannot be inferred from a name alone.
Run pnpm --filter @guestcraft/web test:e2e:messages after the web build with a disposable
PostgreSQL URL. This uses the independent HTTP simulator and Vercel request pumps without a
resident worker. Screenshots go to reports/screens/messages by default: English, light/dark,
1440×900 and 390×844, with axe, keyboard and horizontal-overflow assertions. With the public
Channex OpenAPI downloaded (pnpm channex:spec), messages-contract.unit.test.ts checks the
simulator’s Messages answers and the client’s Live Feed parameters against it; the nightly
channex-drift workflow runs it. See ADR 0044.
Unknown decision outcomes are read back from Channex, with at most three failed read attempts. 403/404 and other definite read refusals end that recovery immediately. When reads cannot prove the outcome, GuestCraft reopens the request and records an uncertainty entry in Activity with the reason and attempt count. No decision is automatically resent. A pending stop can then be retried; after connection access is repaired, an operator retry first checks the channel’s final outcome before attempting a decision.
A special-offer response is compared with the requested amount and currency, as well as its action. If the channel finalized a different price first, GuestCraft preserves that offer, reports a conflict, and records both totals in minor units in Activity. A malformed or missing final total stays uncertain. An exact same-action, same-currency, same-amount decision between the preflight read and submission has no upstream origin marker, so GuestCraft cannot distinguish that channel race from its own action.