Wire conventions β the shapes every QC response follows
One page for the rules every QC endpoint applies to its response. Read this once, then you know how to interpret every list, every name, every money value, and every filter across all endpoints.
Envelope
Every response has this outer shape:
{
"status": "ok", // or "error"
"message": "ok", // or an error translation key
"data": { β¦ }
}
messageis always a translation key, never a rendered string. Match on it, don't parse it β see the error dictionary for the full list.statusisokorerror. HTTP status also disambiguates, but the field lets a client route based on the JSON alone.datais the payload. Its shape depends on the endpoint:
Single-item read/write. data IS the resource object, flat:
{"status": "ok", "message": "ok", "data": {"external_property_id": "β¦", "name": "β¦", β¦}}
List read. data holds a plural-keyed array + a meta block:
{
"status": "ok",
"message": "ok",
"data": {
"bookings": [ β¦ ],
"meta": {"total": 4231, "page": 1, "per_page": 50, "last_page": 85}
}
}
The plural key names the resource (bookings, properties,
room_types, rate_plans, webhook_deliveries, β¦). Never
data.data.
Identifiers
Every resource that can be addressed by an external id emits it
under a canonical external_<resource>_id key AND a back-compat
id alias with the same value:
{"external_property_id": "059020b9-β¦", "id": "059020b9-β¦", β¦}
external_*_idis the recommended read. Path parameters use the same name (/properties/{external_property_id}/β¦), so the wire round-trips exactly.idis preserved for early adopters β it will not be removed, but new consumers should readexternal_*_id.
Internal Quendoo primary keys (property_id, room_id,
rate_id, β¦) are NEVER exposed on the wire. The partner side
never sees them.
Names and translations
Every named entity (property, room type, rate plan, extra, promotion, cancellation policy, payment policy, special offer) emits:
nameβ the hotelier-set label in Quendoo's canonical language (en-GB). Nevernullfor entities that have a name; nullable ONLY when the underlying legacy row cannot be resolved (rare β enrolment refuses to seed a QC map without one).name_trβ the full translation map, keyed by IETF locale tag:{"en-GB": "Superior Sea View", "bg-BG": "Π‘ΡΠΏΠ΅Ρ ΠΈΠ·Π³Π»Π΅Π΄β¦", "ru-RU": "Π£Π»ΡΡΡΠ΅Π½Π½ΡΠΉ β¦"}. Empty object{}when the entity has no translations. Some entities (payment_policy,promotion) are stored without translation columns on the legacy side; they emit a one-locale projection{"en-GB": "<name>"}so callers can loop over_truniformly across every named entity.
Some entities carry additional translated fields
(description_tr, title_tr, text_tr) β see the resource
schemas in the reference.
Money
Every monetary field is {amount, currency}, never a bare float:
{"total_price": {"amount": 234.5, "currency": "EUR"}}
amountis a float in the currency's native unit (2 decimal places for every currency we support).currencyis an ISO 4217 alpha-3 code.
Booking rows carry a top-level currency field as a
convenience for dashboards that render one currency badge per
booking, but every per-line-item price ALSO carries its own
currency in the wrapped shape. A partner storing lines in their
own DB doesn't have to reach outside the row to know what a
number means.
Pagination
Every list endpoint that can grow past ~100 rows takes
?page=N&limit=M and emits a meta block:
"meta": {"total": 4231, "page": 1, "per_page": 50, "last_page": 85}
totalreflects the row count under the ACTIVE filters (not the unfiltered table).last_pageisceil(total / per_page); always at least1even for an empty result, so a UI can render "page 1 of 1" without a null check.per_pageechoes back the effectivelimitafter clamping.limitis clamped to a per-endpointMAX_LIMIT(typically 100β200); a value over the cap is silently reduced.
Some endpoints use CURSOR pagination instead β the booking
revisions feed is the canonical example (?since=<revision_id>
returns the next batch after a known point, with next_cursor
in the response). Cursor endpoints do NOT carry the meta
block; they carry next_cursor and has_more. Each concept
page names which pattern its endpoint uses.
Filters
Filter conventions apply across every endpoint that accepts them:
- Malformed values are DROPPED, not 422. A
?checkin_from= bogusrequest does not narrow β the filter is silently ignored. This is a deliberate Channex-parity choice: partner code that ships a bad filter should still surface the same rows, not fail loudly. - Date filters accept two shapes:
YYYY-MM-DD(day precision) orYYYY-MM-DD HH:MM:SS(second precision). Others are dropped. - Enum filters whitelist to prevent injection. A value not in the enum is dropped, not 422.
- String filters are trimmed and length-capped at endpoint-appropriate limits (typically 32 for identifiers, 100 for guest fields).
Every list endpoint's concept page names its supported filters in a table with types + notes.
Statuses & lifecycles
Two orthogonal status axes on Booking:
processing_stateβ QC ingest-pipeline lifecycle (stagedβpromoted|rejected|duplicate). Partner- useful for "did QC accept my write and move it on to the projector?".booking_statusβ business state (created|confirmed|cancelled|no_show), derived from terminal timestamps. This is what integrators want to render in a UI. Order matters β a cancelled-then- marked-no-show booking reads ascancelledbecause the terminal action happened first when the booking was still active.statusis a deprecated alias toprocessing_state.
Errors
Errors share the envelope with status: "error":
{"status": "error", "message": "errors.qc.unknown_room_type"}
messageis a stable translation key. Match on it, don't parse it.- Validation errors add an
errorsmap:{"scopes.0": [{"key": "validation.in", "params": {β¦}}]}. - HTTP status distinguishes categories:
401(missing/invalid auth),403(auth valid but forbidden),404(unknown resource OR scope),409(idempotency conflict),422(validation),429(rate limit),502(upstream failure).
PII
Guest-level fields (guest_name, first_name, last_name,
email, phone) are MASKED on QC responses even when the
underlying dashboard would show them unmasked to the hotelier.
The reasoning: QC tokens are held by partner PMSes, not the
account holder. See Auth concepts
for the full masking rules.
Idempotency
Every write endpoint honours Idempotency-Key: <uuid> on the
request. See Idempotency for the
contract.
Rate limits
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Reset. See Rate limits.