Developers
The API a booking system talks to when it is going to be the dashboard. One document per
departure, sent as often as you like; a megaphone; the reads the office makes; and a signed
webhook for everything that happens on a phone. Base URL
https://api.cordelle.io, JSON in and out.
What a key can do
Everything the office would otherwise do by hand runs under one API key. You can send a
whole departure — tour, dates, days and their events, hotels, flights, documents,
packing list, guides and the roster — as a single document and correct it by sending
it again; read back the join codes for every traveler and guide; message the group, or
part of it; read who has the trip on a phone, who has seen a message, who tapped a status
and where the people who chose to share are; and register webhook endpoints that hear
those things happen. The one thing you cannot do from the API is create the agency and
mint the key. That is the dashboard: sign up at
app.cordelle.io, open
Integrations, name the key for where it will live and press
Make a key. It is shown once and stored hashed
(the help page for that screen). On a trial
the button works once a card is on file — until then it answers
403 CARD_REQUIRED, like every other create. Adding the card keeps the
remaining trial days and charges nothing until they run out.
Scope. A key is cak_ plus 48 hex characters, sent as a bearer
token. It authenticates only /api/v1; every dashboard, traveler and
guide route rejects it. It cannot see billing, change the team, mint or revoke keys, or
delete a departure or a traveler — the one thing it may remove is one of its
agency’s webhook endpoints. Everything is scoped to the key’s agency: another
agency’s departure, message or endpoint answers 404, never 403.
Rate limits. Per key, per minute: 60 writes (PUT,
POST, DELETE) and 120 reads. Over the limit is
429 RATE_LIMITED with a retry-after header, and every keyed
answer carries x-ratelimit-limit, x-ratelimit-remaining and
x-ratelimit-reset; a 401 carries none.
Plan. The full API — the declarative departure, messages and reads
by ref, webhooks — is part of Pro, and of the 30-day trial, which
runs on Pro’s limits (a trial creates — keys included — once a card is
on file). On Companion a key opens the four roster routes
(GET /api/v1/me, GET /api/v1/departures, and
GET/POST /api/v1/departures/{id}/travelers) and everything else
answers 403 PLAN_FEATURE. Those four older routes take Cordelle’s
departure id in the path; every route below takes your own
externalRef in the same position. The parameter’s name in the OpenAPI
document says which; a ref pasted into one of those four answers 404
NOT_FOUND (“Departure not found”). The id is on every record and list
row.
Quickstart
Five steps from a fresh agency to a webhook you have verified. Every request below runs as printed against the API, and every response is what it answered, ids, codes and timestamps aside — except step 3’s readiness row, which is the OpenAPI document’s example of a traveler mid-trip, since a phone has to join before there is anything to read. Step 2’s document is a trimmed version of that document’s example, with made-up names throughout; every other block is copied from it, and its examples are sent through the real routes by the test suite.
-
Mint a key and prove it works. Dashboard → Integrations → Make a key — on a trial, after a card is on file; the button answers
403 CARD_REQUIREDuntil then. Then ask the API who you are.200 OKexport CORDELLE_KEY=cak_… # pasted from the dashboard, once curl https://api.cordelle.io/api/v1/me \ -H "Authorization: Bearer $CORDELLE_KEY"{ "agencyId": "cmf7ho1a20000pl8x4v2d9q3e", "agencyName": "Example Tours", "keyName": "Booking system" } -
Send a departure, read back the join codes. One
PUTto a path that ends in your id for the departure. The document below is complete: a tour, the dates, two days with events, a hotel with its confirmation number, the group’s two flights in each airport’s own local time, one document, two guides and two travelers.departure.jsoncurl -X PUT "https://api.cordelle.io/api/v1/departures/dep-2027-10-douro" \ -H "Authorization: Bearer $CORDELLE_KEY" \ -H "Content-Type: application/json" \ --data-binary @departure.json{ "tour": { "externalRef": "tour-douro", "name": "Douro by Rail", "description": "Porto to the upper Douro by train and boat." }, "departure": { "name": "October 2027", "startDate": "2027-10-04", "endDate": "2027-10-05", "timezone": "Europe/Lisbon", "status": "published", "guides": [ { "externalRef": "guide-17", "name": "Guida Grande", "phone": "+351 912 000 001", "email": "guida@example.com" }, { "name": "Second Guide" } ] }, "days": [ { "dayNumber": 1, "date": "2027-10-04", "title": "Arrive in Porto", "summary": "Welcome", "items": [ { "time": "09:00", "title": "Airport transfer", "kind": "transfer" }, { "time": "18:00", "title": "Welcome dinner", "kind": "meal", "location": "Hotel" } ] }, { "dayNumber": 2, "date": "2027-10-05", "title": "The river", "items": [ { "time": "10:00", "title": "Rabelo cruise", "kind": "activity" } ] } ], "hotels": [ { "name": "Hotel Exemplo", "city": "Porto", "address": "Rua Um 1", "addressLocal": "Rua Um 1, 4000-001 Porto, Portugal", "phone": "+351 22 000 0000", "wifiName": "douro", "wifiPassword": "vinho", "breakfastHours": "07:00-10:00", "checkInDay": 1, "checkOutDay": 2, "confirmationNumber": "HB-77" } ], "flights": [ { "airline": "Example Air", "flightNumber": "EX 208", "departAirport": "EWR", "departTime": "2027-10-03T20:35", "arriveAirport": "LIS", "arriveTime": "2027-10-04T08:20", "airGroup": "Group A", "confirmationCode": "ABC123" }, { "airline": "Example Air", "flightNumber": "EX 207", "departAirport": "LIS", "departTime": "2027-10-05T17:15", "arriveAirport": "EWR", "arriveTime": "2027-10-05T20:30", "airGroup": "Group A", "confirmationCode": "ABC123" } ], "documents": [ { "label": "Travel insurance policy", "reference": "POL-1", "phone": "+1 800 555 0142", "note": "Call first." } ], "travelers": [ { "externalRef": "bk-1", "firstName": "Alice", "lastName": "Anders", "email": "alice@example.com", "party": "Anders" }, { "externalRef": "bk-2", "firstName": "Ben", "lastName": "Baxter", "phone": "+1 555 0100" } ] }Flight times are the wall clock at that airport —
201 Created20:35is 20:35 in Newark. The server pins each airport’s own offset; never send UTC.checkInDayandcheckOutDayare day numbers indays. A hotel’s confirmation number and a flight’s record locator are stored exactly as sent after trimming.{ "id": "cmf7hq3k50001pl8xg6y0b2rt", "externalRef": "dep-2027-10-douro", "name": "October 2027", "startDate": "2027-10-04", "endDate": "2027-10-05", "timezone": "Europe/Lisbon", "status": "published", "tour": { "id": "cmf7hq3k50002pl8x1w7c5m8n", "externalRef": "tour-douro", "name": "Douro by Rail", "description": "Porto to the upper Douro by train and boat." }, "bundleVersion": 1, "lastPublishedAt": "2027-09-01T10:15:02.310Z", "unpublishedChanges": false, "counts": { "days": 2, "items": 3, "hotels": 1, "flights": 2, "documents": 1, "packing": 0, "travelers": 2, "guides": 2 }, "guides": [ { "id": "cmf7hq3k60003pl8xz9p4d1ka", "externalRef": "guide-17", "name": "Guida Grande", "phone": "+351 912 000 001", "email": "guida@example.com", "joinCode": "GQ7M4K", "isLead": true }, { "id": "cmf7hq3k60004pl8xm2r8s7vb", "externalRef": null, "name": "Second Guide", "phone": null, "email": null, "joinCode": "XR4T9N", "isLead": false } ], "roster": [ { "id": "cmf7hq3k70005pl8xq5t3n6jc", "externalRef": "bk-1", "firstName": "Alice", "lastName": "Anders", "joinCode": "HJ8W3P", "joinedAt": null }, { "id": "cmf7hq3k70006pl8xh8u1e4wd", "externalRef": "bk-2", "firstName": "Ben", "lastName": "Baxter", "joinCode": "KZ2N6Q", "joinedAt": null } ], "created": true, "changed": true, "changedSections": ["tour", "departure", "guides", "days", "hotels", "flights", "documents"], "published": true, "travelers": { "created": 2, "updated": 0, "unchanged": 0, "missingFromPayload": [], "unmanaged": 0 } }The join codes are in the answer —
roster[].joinCodefor each traveler,guides[].joinCodefor each guide — and the same record is yours to read again at any time, so your own system can print the packet:curl "https://api.cordelle.io/api/v1/departures/dep-2027-10-douro" \ -H "Authorization: Bearer $CORDELLE_KEY"Send the same document again and the answer is
200with"changed": falseand nothing written. Change one hotel and onlyhotelsis rewritten and republished. The declarative departure below has the rules. -
Put a join code on a phone. Nothing to call. The traveler installs Cordelle from the App Store or Google Play, opens it and types their six characters —
HJ8W3Pfor Alice — or openshttps://cordelle.io/j/HJ8W3P, which opens the app if it is installed and otherwise shows the code with the store links. The whole trip downloads to the phone, rebranded as your agency. A guide signs in the same way with their own code (GQ7M4K) and chooses a PIN the first time.You can watch it land. The readiness read is the dashboard’s Travelers tab, computed by the same function:
200 OKcurl "https://api.cordelle.io/api/v1/departures/dep-2027-10-douro/readiness" \ -H "Authorization: Bearer $CORDELLE_KEY"{ "currentVersion": 1, "travelers": [ { "traveler": { "id": "cmf7hq3k70005pl8xq5t3n6jc", "firstName": "Alice", "lastName": "Anders", "email": "alice@example.com", "phone": null, "party": "Anders", "externalRef": "bk-1", "createdAt": "2027-09-01T10:15:01.980Z" }, "joined": true, "bundleVersion": 1, "currentVersion": 1, "lastSyncAt": "2027-10-04T06:12:40.000Z", "quickStatus": { "status": "running_late", "at": "2027-10-04T12:41:02.000Z" }, "unseenMessages": 1 } ] }If you registered a webhook (step 5) before Alice joined, a
traveler.joineddelivery rings your endpoint the first time she does. -
Send a message to everyone, then read the receipts. The same send the dashboard composer and the guide’s phone use.
clientKeyis your idempotency key: a retry with the same key and the same audience answers200with the original message instead of sending twice.201 Createdcurl -X POST "https://api.cordelle.io/api/v1/departures/dep-2027-10-douro/messages" \ -H "Authorization: Bearer $CORDELLE_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Bus leaves at 2:00", "body": "Meet in the lobby at 1:45.", "clientKey": "send-2027-10-04-001" }'{ "id": "cmf7hu9z10007pl8xk3f7a2ye", "title": "Bus leaves at 2:00", "body": "Meet in the lobby at 1:45.", "priority": "normal", "createdAt": "2027-10-04T11:02:44.120Z", "createdByName": "Booking system (API)", "total": 2, "delivered": 0, "seen": 0, "audience": { "kind": "all", "party": null, "count": 2, "label": "Everyone" }, "clientKey": "send-2027-10-04-001" }createdByNameis the key’s name with(API)appended. It is never taken from the request. Then the exception list — every addressed traveler with when their phone fetched it (the inbox read; downloading the trip bundle alone stamps nothing) and when they opened it:200 OKcurl "https://api.cordelle.io/api/v1/departures/dep-2027-10-douro/messages/cmf7hu9z10007pl8xk3f7a2ye/receipts" \ -H "Authorization: Bearer $CORDELLE_KEY"{ "receipts": [ { "traveler": { "id": "cmf7hq3k70005pl8xq5t3n6jc", "firstName": "Alice", "lastName": "Anders", "email": "alice@example.com", "phone": null, "party": "Anders", "externalRef": "bk-1", "createdAt": "2027-09-01T10:15:01.980Z" }, "deliveredAt": "2027-10-04T11:03:10.000Z", "seenAt": null } ] } -
Register a webhook and verify a signature. The URL must be
https://at a public hostname. Thesecretin the answer is shown once.201 Createdcurl -X POST "https://api.cordelle.io/api/v1/webhooks" \ -H "Authorization: Bearer $CORDELLE_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/cordelle", "events": ["traveler.joined", "quick_status.created", "sos.opened", "sos.handled", "message.seen", "departure.published"], "description": "Production" }'{ "endpoint": { "id": "cmf7hw2c40008pl8xb6g9l5zf", "url": "https://hooks.example.com/cordelle", "events": ["traveler.joined", "quick_status.created", "sos.opened", "sos.handled", "message.seen", "departure.published"], "description": "Production", "active": true, "paused": null, "deadStreak": 0, "createdByName": "Booking system", "createdAt": "2027-09-01T10:14:30.000Z", "lastDeliveredAt": null, "lastFailure": null }, "secret": "whsec_9f1c4a2e7b8d3c6f0a5e2d9b4c7f1a8e3d6b0c9f2a5e8d1b4c7f0a3e6d9b2c5f" }Ask for a test ping. It answers
202at once with the delivery’s row — itsidis theX-Cordelle-Deliveryheader of what arrives, and its line in…/deliveries— and the delivery itself arrives a moment later.202 Acceptedcurl -X POST "https://api.cordelle.io/api/v1/webhooks/cmf7hw2c40008pl8xb6g9l5zf/test" \ -H "Authorization: Bearer $CORDELLE_KEY"What arrives at your URL{ "endpoint": { "id": "cmf7hw2c40008pl8xb6g9l5zf", … }, "delivery": { "id": "cmf7hw2c40009pl8xd1h2o8xg", "eventId": "5b0d7e0e-3a5e-4a1b-9d4a-2c1f0f0e1a2b", "event": "ping", "status": "pending", "attempt": 0, "statusCode": null, "error": null, "nextAttemptAt": "2027-10-04T17:41:03.120Z", "lastAttemptAt": null, "deliveredAt": null, "createdAt": "2027-10-04T17:41:03.120Z" } }POST /cordelle HTTP/1.1 Content-Type: application/json User-Agent: Cordelle-Webhooks/1 X-Cordelle-Event: ping X-Cordelle-Delivery: cmf7hw2c40009pl8xd1h2o8xg X-Cordelle-Attempt: 1 X-Cordelle-Timestamp: 1822671663 X-Cordelle-Signature: v1=<hex> { "id": "5b0d7e0e-3a5e-4a1b-9d4a-2c1f0f0e1a2b", "event": "ping", "createdAt": "2027-10-04T17:41:03.120Z", "agencyId": "cmf7ho1a20000pl8x4v2d9q3e", "data": { "endpoint": { "id": "cmf7hw2c40008pl8xb6g9l5zf" }, "sentAt": "2027-10-04T17:41:03.120Z" } }Verify it: HMAC-SHA256 under the secret over the timestamp, an ASCII full stop, and the raw body bytes exactly as received; compare in constant time; refuse a stale timestamp. Read the raw body before any JSON parser touches it.
Nodeimport { createHmac, timingSafeEqual } from "node:crypto"; function verify(secret, headers, rawBody) { const ts = headers["x-cordelle-timestamp"]; if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const expected = "v1=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex"); const given = headers["x-cordelle-signature"] ?? ""; return expected.length === given.length && timingSafeEqual(Buffer.from(expected), Buffer.from(given)); }Answer any
2xxwithin 5 seconds. The Python version, the retry schedule and every payload are under Webhooks.
The declarative departure
PUT /api/v1/departures/{externalRef} carries the whole departure; the server
makes the stored one match it and reports what it did. Send it on every change in your
system, or on a timer — hourly is fine — and it is safe either way.
Sections
- A present section is the whole truth for that section.
days,hotels,flights,documentsandpackingare replace-all:"hotels": []clears the hotels.departure.guidesis the ordered assignment — the first is the lead,[]assigns nobody. - An omitted section is left exactly as it is. A document that carries
only
tour,departureandtravelerstouches nothing else. (touranddepartureare always required.) - Travelers merge and are never deleted.
externalRefis required on every entry and is the match key. A ref not on the roster creates the traveler (firstNameandlastNamerequired then); a ref already on it updates the fields that are present —nullclears one, an absent key leaves it alone. The fields areexternalRef,firstName,lastName,email,phone,party,dietaryNotes,mobilityNotes,emergencyContactNameandemergencyContactPhone; the last four are write-only under the key — no read here returns them. A traveler on the roster whose ref you did not mention is reported intravelers.missingFromPayloadand kept: an hourly sync must never be able to delete somebody’s join code by omission. Travelers typed or imported on the dashboard have no ref, are counted intravelers.unmanaged, and cannot be addressed here. At most 1,000 travelers per document; a bigger roster is several sends. - An unknown key anywhere is refused by name — a misspelt section
or a misspelt field of any element (
travelers[0]: unknown field "dietaryNote") — never dropped, so a typo can never silently do nothing.
Tour and guides
The tour is found by externalRef within your agency, else by exact
name (only a tour with no ref of its own, or when you sent none), else
created. A found tour is renamed or re-described to match, and a ref it lacks is stamped
on it. Each guide entry is resolved by externalRef, else email
(case-insensitive), else name (exact, case-insensitive, active guides) and
created when nothing matches — a create needs a name; the guide
chooses their PIN on first sign-in. A matched guide is corrected in place for the fields
the entry carried, and that correction shows on every departure they work. Two guides
matching one name is 409 GUIDE_AMBIGUOUS; a retired guide is
409 GUIDE_ARCHIVED; a guide already carrying a different ref is
409 GUIDE_REF_CONFLICT.
Republish only on change
Every content section is compared with what is stored and rewritten only when it differs.
The same document twice writes nothing the second time and answers
200 { "changed": false, "changedSections": [] } with the same
bundleVersion; the first send answers 201 { "created": true }.
changedSections names the content sections this send rewrote
(tour, departure, guides, days,
hotels, flights, documents, packing).
The roster is not bundle content: a send that only changes travelers writes them, answers
"changed": true, and publishes nothing.
{
"id": "cmf7hq3k50001pl8xg6y0b2rt",
"externalRef": "dep-2027-10-douro",
…
"bundleVersion": 1,
"lastPublishedAt": "2027-09-01T10:15:02.310Z",
"unpublishedChanges": false,
…
"created": false,
"changed": false,
"changedSections": [],
"published": false,
"travelers": { "created": 0, "updated": 0, "unchanged": 2, "missingFromPayload": [], "unmanaged": 0 }
}What status does, and does not do
"status": "published"means make sure a bundle carrying the current content is out. A bundle is compiled when this send changed a content section, when nothing was ever published, when content newer than the latest bundle is waiting (an edit made on the dashboard, or an earlier send whose publish failed after its content landed), or when the stored status isdraft. The answer’spublishedsays whether this send compiled one;bundleVersionis then the new version."status": "draft"— or omitted — never publishes on its own, and never un-publishes: there is no such thing as taking a bundle off a phone. A departure whose stored status is alreadypublishedoractivestill republishes whenever a content section changed, whatever the payload says.- A departure whose stored status is
completeis never republished here — the trip is a record — but its content and roster are still corrected.activeandcompleteare set by the lifecycle sweep from the dates — hourly, judging today in the departure’s own timezone; you cannot send them.
One transaction, one lock
Validation against stored state, every write and the publish run inside one transaction that first takes an advisory lock on your agency. Every send of one agency serializes, whatever departure it names: the first creates, the rest find it and diff. It is per agency rather than per ref because a document also creates the tours and guides an agency shares across departures, and two first sends naming the same new tour or guide must mint it once. Two agencies never wait on each other; a send is tens of milliseconds. A document is applied and published as one unit or not at all.
What a refusal looks like
Refusals write nothing. Every 400 VALIDATION names the section
and the element — days[3].items[1]: time must be "HH:MM",
hotels[0]: hotel "Hotel Exemplo": checkInDay/checkOutDay must match existing itinerary
dayNumbers, travelers[1]: externalRef "bk-1" is already used by
travelers[0], departure: startDate and endDate must be valid YYYY-MM-DD
dates. The element’s own message is the dashboard’s, verbatim, because it
is the dashboard’s validator that produced it. A flight date or time that does not
exist (2027-02-30, 25:99) is refused naming the flight, never
rolled into a different date; an unknown IATA code is refused naming it. A refusal judged
against stored state rolls back the sections already applied.
{ "error": { "code": "VALIDATION", "message": "days[0].items[1]: time must be \"HH:MM\"" } }
Plan gates are exactly the dashboard’s. The route is the full
API’s, so a Companion key, a canceled subscription or an ended trial answers
403 PLAN_FEATURE before the document is read. Behind that fence, a document
that would create anything — the departure, a tour, a guide, a traveler, a
longer documents list — answers the create gate: 403 CARD_REQUIRED while
a card-first trial has no card yet, the message naming what it would have created. The
gate’s PLAN_LIMIT is never the answer here: a lapsed plan has already
met the fence, and the active-departure cap is Companion’s, which has no
PUT. The whole document is refused: a send that carries both a correction and
a new traveler applies neither. Correcting an existing departure’s content is never
gated, so a live trip stays correctable.
{ "error": { "code": "CARD_REQUIRED", "message": "Your free 30-day trial is already running — add a card to start building. It won't be charged until the trial ends. (This document would create the departure, 2 travelers.)" } }
A ref that is not yours is 404 NOT_FOUND on every
GET and on every route under the departure — a ref this agency has
never sent, or another agency’s identical ref, answer the same, never
403. The ref itself is URL-encoded in the path, trimmed, 1 to 200 characters,
no control characters (400 otherwise; a %00 decodes to a NUL
and is refused as one). A departure made on the dashboard has no ref and cannot be reached
by these routes.
{ "error": { "code": "NOT_FOUND", "message": "No departure carries that externalRef" } }Messages and reads
Every route here is addressed by the departure’s externalRef, exactly
as above, and every traveler in every answer carries their externalRef, so
your system can join a receipt, a readiness row, a status or a pin back to its own record.
Care notes and emergency contacts are never read here.
Sending
POST …/messages runs the dashboard composer’s validator and function.
title is required (at most 120 characters); body is optional (at
most 4,000; stored "" when left out); priority is
normal or urgent. A title or body still carrying a starter blank
(a run of two or more underscores) is 400 UNFILLED_BLANK; nothing is sent and
the clientKey is not consumed. Three audience forms:
audience)
{ "title": "Bus leaves at 2:00", "body": "Meet in the lobby at 1:45.", "clientKey": "send-2027-10-04-001" }{
"title": "Your transfer is at 9:00",
"priority": "urgent",
"audience": { "kind": "travelers", "travelerRefs": ["bk-1", "bk-2"] },
"clientKey": "send-2027-10-04-002"
}{
"title": "Table for the Anders party",
"body": "Reserved under Anders at 7:30.",
"audience": { "kind": "party", "party": "Anders" }
}- Refs, not ids.
travelerRefstakes the travelers’externalRefs (trimmed, 1 to 200 each, duplicates collapse, at most 1,000);travelerIdsis not accepted here. A ref that is not on this departure is400 VALIDATIONnaming the refs that are not (These externalRefs are not on this departure: "bk-9", "bk-12". Nothing was sent.— singular for one ref) — they are your own ids, so naming them reveals nothing. Party membership is whoever carries the label at the moment of the send. - Author.
createdByNameis the key’s name with(API)appended, snapshotted at send time, so a guide reading the sent list knows no person pressed send. A key cannot sign a message as a member of staff. - Idempotent by
clientKey, audience included. 1 to 64 characters. A replay — same key, same audience,travelerRefsin any order — answers200with the original message. The same key aimed at different people, a different party, or everyone is409 CLIENT_KEY_REUSEDnaming who it already went to, and sends nothing. - Receipts. The answer is the dashboard’s row:
totalis the audience size,deliveredhow many phones have fetched it,seenhow many travelers have opened it. Receipts exist only for the audience, and push goes only to the audience’s phones.
Reading
| Route | Answers |
|---|---|
GET …/messages |
Every message on the departure, newest first — sent here, from the dashboard or from a guide’s phone — with its audience summary, live counts and the clientKey it was sent with (null for one sent without, or from the dashboard or a guide). |
GET …/messages/{id}/receipts |
The exception list: every addressed traveler with deliveredAt and seenAt. The message must belong to the departure in the path; any other message — this agency’s included — is 404. |
GET …/readiness |
Who has the trip on a phone: one row per traveler on the roster, those with no ref included ("externalRef": null) — joined, the bundleVersion their phone last confirmed against currentVersion, lastSyncAt, unseenMessages (messages addressed to them they have not opened) and their quickStatus if it is under 12 hours old. |
GET …/quick-statuses |
The guide’s cheap poll: one row per traveler with a status under 12 hours old (their latest, newest first), nothing for anyone else. fresh equals statuses.length. |
GET …/locations |
The dashboard’s map data under the dashboard’s consent rules, decided by the same function. |
Quick statuses
A traveler can tap one of three fixed choices on their phone: on_my_way,
running_late, sitting_out. A status never carries text —
there is no note field on the request, the row, or any read, and there will not be one.
at is the tap time on the device’s clock, never ahead of the
server’s. A status is fresh for 12 hours; after that it drops out of every
read.
{
"asOf": "2027-10-04T12:45:00.000Z",
"fresh": 1,
"statuses": [
{ "travelerId": "cmf7hq3k70005pl8xq5t3n6jc", "externalRef": "bk-1", "status": "running_late", "at": "2027-10-04T12:41:02.000Z" }
]
}Locations, under consent
Location sharing is off by default. A traveler turns it on for a timed session that
expires by itself, and the app never asks for background location. So a coordinate
appears in this read only while the traveler’s consent session is active
("consent": "session", with consentExpiresAt), or when their most
recent ping is one they sent on purpose — an SOS, or a manual share. Otherwise
lastPing is null. An open SOS is never hidden. The quick status
rides alongside and is not consent-gated: it is a choice the traveler made to be seen. A
key can never see a coordinate the dashboard would withhold; the test suite asserts the
two answers are equal row for row.
{
"asOf": "2027-10-04T12:45:00.000Z",
"travelers": [
{
"traveler": {
"id": "cmf7hq3k70005pl8xq5t3n6jc",
"firstName": "Alice",
"lastName": "Anders",
"email": "alice@example.com",
"phone": null,
"party": "Anders",
"externalRef": "bk-1",
"createdAt": "2027-09-01T10:15:01.980Z"
},
"consent": "session",
"consentExpiresAt": "2027-10-04T14:00:00.000Z",
"lastPing": {
"lat": 41.1496, "lng": -8.611, "accuracyM": 12,
"capturedAt": "2027-10-04T12:44:10.000Z",
"receivedAt": "2027-10-04T12:44:12.500Z",
"kind": "auto",
"sosClearedAt": null
},
"quickStatus": { "status": "running_late", "at": "2027-10-04T12:41:02.000Z" }
}
]
}
kind is manual, auto, checkin or
sos; manual and sos are one-shots the traveler sent on
purpose, the other two need an active session. For an SOS, sosClearedAt is set
once the traveler cancels it or the guide marks it handled.
Webhooks
An endpoint registered under the key hears the dashboard’s live signals as a signed
POST, retried on a schedule, and paused when it is dead for good.
A webhook is a doorbell, never the record. Every payload carries Cordelle
ids and your own externalRefs and nothing else — no names, no phone
numbers, no care notes, no message text, no coordinates. The system that hears the bell
reads the details with its key, under the key’s own consent rules.
Registering
POST /api/v1/webhooks with { url, events, description? }. The
url must be https:// at a public hostname: no
http, no IP literal, no localhost, no bare or
.internal/.local name, no username or password, no
#fragment, no port 0, at most 2,000 characters. The hostname is judged by what
it means in DNS, not how it is spelled — localhost. and
localhost%2e are localhost. events is a non-empty
subset of the six below; an unknown name is refused by name, duplicates collapse.
description is your own note, at most 200 characters.
Five live endpoints per agency; the sixth is 400. The
secret in the answer — whsec_ plus 64 hex characters
— is shown once and never again.
GET /api/v1/webhooks lists the live endpoints, oldest first, never with a
secret. DELETE /api/v1/webhooks/{id} removes one for good (200
twice is fine; its queued deliveries die). GET /api/v1/webhooks/{id}/deliveries
is the log: the newest 50, each with status (pending,
delivered, dead), attempt, statusCode,
error, nextAttemptAt (meaningful only while pending;
a delivered row keeps its last value), lastAttemptAt and
deliveredAt.
Events
The envelope is the same for every event: id is the event id
— the same in every endpoint’s copy and in every retry, so a system subscribed
twice can dedupe on it; createdAt is when the thing happened;
data.departure and data.traveler are always
{ "id", "externalRef" }, with externalRef null for
one the dashboard made. The ping alone serialises event second; the key set
is the same.
traveler.joined — the first time a traveler joins
with their code; later sign-ins ring nobody.
{
"id": "5b0d7e0e-3a5e-4a1b-9d4a-2c1f0f0e1a2b",
"createdAt": "2027-10-04T17:41:03.120Z",
"agencyId": "cmf7ho1a20000pl8x4v2d9q3e",
"event": "traveler.joined",
"data": {
"departure": { "id": "cmf7hq3k50001pl8xg6y0b2rt", "externalRef": "dep-2027-10-douro" },
"traveler": { "id": "cmf7hq3k70005pl8xq5t3n6jc", "externalRef": "bk-1" },
"joinedAt": "2027-10-04T17:41:03.000Z"
}
}quick_status.created — a new tap, never a replay of
one already recorded.
{
"id": "5b0d7e0e-3a5e-4a1b-9d4a-2c1f0f0e1a2b",
"createdAt": "2027-10-04T17:41:03.120Z",
"agencyId": "cmf7ho1a20000pl8x4v2d9q3e",
"event": "quick_status.created",
"data": {
"departure": { "id": "cmf7hq3k50001pl8xg6y0b2rt", "externalRef": "dep-2027-10-douro" },
"traveler": { "id": "cmf7hq3k70005pl8xq5t3n6jc", "externalRef": "bk-1" },
"status": "running_late",
"at": "2027-10-04T17:41:02.000Z"
}
}sos.opened — a traveler with no open SOS raises one. One per
episode: further SOS pings while it is open are position updates, not new alarms.
No position in the payload; read …/locations.
{
"id": "5b0d7e0e-3a5e-4a1b-9d4a-2c1f0f0e1a2b",
"createdAt": "2027-10-04T17:41:03.120Z",
"agencyId": "cmf7ho1a20000pl8x4v2d9q3e",
"event": "sos.opened",
"data": {
"departure": { "id": "cmf7hq3k50001pl8xg6y0b2rt", "externalRef": "dep-2027-10-douro" },
"traveler": { "id": "cmf7hq3k70005pl8xq5t3n6jc", "externalRef": "bk-1" },
"capturedAt": "2027-10-04T17:40:58.000Z"
}
}sos.handled — the guide’s “mark handled”
("by": "guide") or the traveler’s “I’m OK now”
("by": "traveler") closes an open SOS.
{
"id": "5b0d7e0e-3a5e-4a1b-9d4a-2c1f0f0e1a2b",
"createdAt": "2027-10-04T17:41:03.120Z",
"agencyId": "cmf7ho1a20000pl8x4v2d9q3e",
"event": "sos.handled",
"data": {
"departure": { "id": "cmf7hq3k50001pl8xg6y0b2rt", "externalRef": "dep-2027-10-douro" },
"traveler": { "id": "cmf7hq3k70005pl8xq5t3n6jc", "externalRef": "bk-1" },
"by": "guide",
"clearedAt": "2027-10-04T17:52:10.000Z"
}
}message.seen — a traveler opened a message (the transition only;
replays are silent). No text: message.clientKey joins it to your own send.
{
"id": "5b0d7e0e-3a5e-4a1b-9d4a-2c1f0f0e1a2b",
"createdAt": "2027-10-04T17:41:03.120Z",
"agencyId": "cmf7ho1a20000pl8x4v2d9q3e",
"event": "message.seen",
"data": {
"departure": { "id": "cmf7hq3k50001pl8xg6y0b2rt", "externalRef": "dep-2027-10-douro" },
"traveler": { "id": "cmf7hq3k70005pl8xq5t3n6jc", "externalRef": "bk-1" },
"message": { "id": "cmf7hu9z10007pl8xk3f7a2ye", "clientKey": "send-2027-10-04-001" },
"seenAt": "2027-10-04T11:09:31.000Z"
}
}departure.published — a bundle was compiled: by the dashboard’s
Publish, or by your PUT when something moved (never on an identical
resend).
{
"id": "5b0d7e0e-3a5e-4a1b-9d4a-2c1f0f0e1a2b",
"createdAt": "2027-10-04T17:41:03.120Z",
"agencyId": "cmf7ho1a20000pl8x4v2d9q3e",
"event": "departure.published",
"data": {
"departure": { "id": "cmf7hq3k50001pl8xg6y0b2rt", "externalRef": "dep-2027-10-douro" },
"version": 2,
"publishedAt": "2027-10-01T09:30:00.000Z"
}
}ping — sent by POST /api/v1/webhooks/{id}/test to that
endpoint whatever it subscribes to. The body is in step 5 of the quickstart.
Headers
| Header | Value |
|---|---|
X-Cordelle-Event | The event name (quick_status.created, ping, …). |
X-Cordelle-Delivery | The delivery id — one per endpoint per event, the same across that delivery’s retries. |
X-Cordelle-Attempt | 1 on the first try, then 2… |
X-Cordelle-Timestamp | Unix seconds at which this attempt was sent — fresh on every retry. |
X-Cordelle-Signature | v1=<hex> |
User-Agent | Cordelle-Webhooks/1 |
The signature
hex is HMAC-SHA256 under the endpoint’s secret over the string
<X-Cordelle-Timestamp>.<raw request body> — the timestamp,
an ASCII full stop, then the body bytes as received, never re-serialised.
The body is the stored payload and is byte-identical on every retry; the timestamp is fresh
per attempt. To verify: read the raw body, recompute, compare in constant time, and refuse
a timestamp older than you like. Five minutes is customary; that is your replay window.
The Node version is in step 5 of the quickstart. In Python:
import hmac, hashlib, time
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
ts = headers.get("x-cordelle-timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest("v1=" + mac, headers.get("x-cordelle-signature", ""))
Answer any 2xx within 5 seconds and the delivery is done; the
body is ignored. Redirects are not followed — a 3xx is
a failure, because the signed body must reach the URL registered — as is any other
status, no answer within 5 seconds, or a connection error.
Retries
The request that causes an event never calls your URL: the event only enqueues a delivery,
in the same transaction as the fact it announces, so a slow or dead endpoint can never slow
a traveler’s sync or an SOS. A sweep posts it within about a minute. After a failure
the next attempt is 1 min, 5 min, 30 min, 2 h, 12 h after each failed one;
when the sixth attempt fails the delivery is dead. The delivery’s
error says why in a few words — HTTP 503,
no answer within 5s, fetch failed (ENOTFOUND) for a name that did
not resolve, and connection failed for every other transport failure. The text
is deliberately no finer than that: the sweep runs inside the platform’s network, and
a text that told “refused” from “reset” would say which internal
ports answer. Your own server’s logs say why a connection failed. Order across
deliveries is not guaranteed; a retry can arrive after a later event’s first attempt.
Dedupe on the envelope id.
Pausing, and the ping that resumes
An endpoint whose deliveries die 20 in a row is paused:
"active": false, paused.reason says so in plain words and names
the last error, its still-queued deliveries die, and nothing further is enqueued for it.
Any delivered one resets the streak. POST /api/v1/webhooks/{id}/test resumes
it — “I fixed it, try again” — clearing the pause and the streak and
sending a ping. Delivery rows are kept for 30 days; the endpoint, its streak
and its last failure stay.
Webhooks are part of the full API. After a downgrade to Companion, or when a plan lapses,
nothing further is enqueued; a delivery already queued dies with plan does not include
webhooks and does not count against the endpoint’s streak. When the feature is
back the next event enqueues and delivers, with nothing to resume.
Errors and limits
Every refusal, on every route, is one envelope. code is a stable word;
message is a sentence for a person, naming the field, the section and the
index that failed.
{ "error": { "code": "VALIDATION", "message": "days[3].items[1]: time must be \"HH:MM\"" } }| Status | Code | When |
|---|---|---|
| 400 | VALIDATION | A field, section or path ref broke a rule; the message names it. Includes a body that is not valid JSON, a text/plain body, and a NUL character anywhere. |
| 400 | UNFILLED_BLANK | A message title or body still carries a ___ starter blank. |
| 401 | UNAUTHORIZED | No key, something that is not one, an unknown key, or a revoked key — three different messages. |
| 403 | PLAN_FEATURE | The key’s agency is on Companion (or a lapsed plan) and the route is part of the full API. |
| 403 | CARD_REQUIRED, PLAN_LIMIT | The create gate, on what a request would create. CARD_REQUIRED: a card-first trial has no card yet. PLAN_LIMIT: the trial has ended or the subscription is canceled — reachable only on POST /api/v1/departures/{id}/travelers, the one create on every plan; a full-API route meets PLAN_FEATURE first. The message names what would have been created. Nothing written. |
| 404 | NOT_FOUND | A departure, message or endpoint that is not this agency’s — never 403. |
| 409 | CLIENT_KEY_REUSED | The clientKey was already used to send to a different audience. |
| 409 | GUIDE_AMBIGUOUS, GUIDE_ARCHIVED, GUIDE_REF_CONFLICT | A guide entry could not be resolved to one active person. Nothing written. |
| 413 | FST_ERR_CTP_BODY_TOO_LARGE | The body is over 1 MB (1,048,576 bytes). The HTTP layer’s code, not a Cordelle word. |
| 415 | FST_ERR_CTP_INVALID_MEDIA_TYPE | The Content-Type is anything but application/json (except text/plain, which reaches the route and is refused as 400). Also the HTTP layer’s. |
| 429 | RATE_LIMITED | Over 60 writes or 120 reads a minute for this key. retry-after says how long. |
| 503 | INTEGRATIONS_UNCONFIGURED | Registering a webhook on an install that cannot store secrets. Not the case in production. |
| 500 | INTERNAL | Ours. Nothing about the cause is in the message. |
Limits
- Every free-text field in a body: 10,000 characters (
400naming the field). Every body: 1 MB — a departure at every section cap below would not fit in one body; the caps are ceilings, not a size the route promises to take in one send. - Per departure: 400 days, 100 events a day, 200 packing items, 40 documents (label 80, reference 80, phone 80, note 500). Hotel confirmation numbers and flight record locators: 40 characters.
travelersper document: 1,000. External refs: 200 characters, in a path too, unique within the list.- Messages: title 120, body 4,000, a hand-picked
audience of 1,000 refs,
clientKey1 to 64 characters. - Webhooks: 5 live endpoints per agency, URL 2,000 characters, description 200, 6 attempts per delivery, 5-second timeout, 20 dead in a row pauses the endpoint, deliveries kept 30 days, the newest 50 listed.
NUL and format characters. No string anywhere — a body field, a path
segment, a query value — may carry a NUL (\u0000): 400
VALIDATION naming it, on every route. The fields a traveler reads off a card or
dials — a hotel’s confirmation number, a flight’s record locator, a
document’s reference and phone — are stored exactly as sent after trimming,
never re-cased or stripped of a dash, and are refused rather than cleaned if they carry any
control or format character: a tab, a zero-width space, a bidi override. A
document’s note may break a line and nothing else.
Reference
The whole surface is described in one OpenAPI 3.1 document, served by the API itself:
https://api.cordelle.io/api/v1/openapi.jsonLoad it in any OpenAPI viewer. It needs no key, answers cross-origin to any page, and is cached for five minutes; it changes only with a deploy. It is hand-written beside the routes and held to them by the test suite: every registered route appears in it, every example is sent through the route it documents, and every response schema is closed, so a field the server grows without anyone documenting it fails the test suite.
For the dashboard side — minting and revoking keys, WeTravel, Zapier — see Integrations in the help center. Questions about the API go to [email protected] and are answered by the person who wrote it.