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.

  1. 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_REQUIRED until then. Then ask the API who you are.

    export CORDELLE_KEY=cak_…   # pasted from the dashboard, once
    
    curl https://api.cordelle.io/api/v1/me \
      -H "Authorization: Bearer $CORDELLE_KEY"
    200 OK
    {
      "agencyId": "cmf7ho1a20000pl8x4v2d9q3e",
      "agencyName": "Example Tours",
      "keyName": "Booking system"
    }
  2. Send a departure, read back the join codes. One PUT to 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.

    curl -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
    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 airport20:35 is 20:35 in Newark. The server pins each airport’s own offset; never send UTC. checkInDay and checkOutDay are day numbers in days. A hotel’s confirmation number and a flight’s record locator are stored exactly as sent after trimming.

    201 Created
    {
      "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[].joinCode for each traveler, guides[].joinCode for 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 200 with "changed": false and nothing written. Change one hotel and only hotels is rewritten and republished. The declarative departure below has the rules.

  3. 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 — HJ8W3P for Alice — or opens https://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:

    curl "https://api.cordelle.io/api/v1/departures/dep-2027-10-douro/readiness" \
      -H "Authorization: Bearer $CORDELLE_KEY"
    200 OK
    {
      "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.joined delivery rings your endpoint the first time she does.

  4. Send a message to everyone, then read the receipts. The same send the dashboard composer and the guide’s phone use. clientKey is your idempotency key: a retry with the same key and the same audience answers 200 with the original message instead of sending twice.

    curl -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" }'
    201 Created
    {
      "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"
    }

    createdByName is 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:

    curl "https://api.cordelle.io/api/v1/departures/dep-2027-10-douro/messages/cmf7hu9z10007pl8xk3f7a2ye/receipts" \
      -H "Authorization: Bearer $CORDELLE_KEY"
    200 OK
    {
      "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
        }
      ]
    }
  5. Register a webhook and verify a signature. The URL must be https:// at a public hostname. The secret in the answer is shown once.

    curl -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"
      }'
    201 Created
    {
      "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 202 at once with the delivery’s row — its id is the X-Cordelle-Delivery header of what arrives, and its line in …/deliveries — and the delivery itself arrives a moment later.

    curl -X POST "https://api.cordelle.io/api/v1/webhooks/cmf7hw2c40008pl8xb6g9l5zf/test" \
      -H "Authorization: Bearer $CORDELLE_KEY"
    202 Accepted
    {
      "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"
      }
    }
    What arrives at your URL
    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.

    Node
    import { 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 2xx within 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

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.

200 OK — the same document a second time
{
  "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

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.

400 Bad Request
{ "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.

403 Forbidden
{ "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.

404 Not Found
{ "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:

Everyone on the roster (or omit audience)
{ "title": "Bus leaves at 2:00", "body": "Meet in the lobby at 1:45.", "clientKey": "send-2027-10-04-001" }
A hand-picked few, by your refs
{
  "title": "Your transfer is at 9:00",
  "priority": "urgent",
  "audience": { "kind": "travelers", "travelerRefs": ["bk-1", "bk-2"] },
  "clientKey": "send-2027-10-04-002"
}
One party
{
  "title": "Table for the Anders party",
  "body": "Reserved under Anders at 7:30.",
  "audience": { "kind": "party", "party": "Anders" }
}

Reading

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

200 OK — …/quick-statuses
{
  "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.

200 OK — …/locations
{
  "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

HeaderValue
X-Cordelle-EventThe event name (quick_status.created, ping, …).
X-Cordelle-DeliveryThe delivery id — one per endpoint per event, the same across that delivery’s retries.
X-Cordelle-Attempt1 on the first try, then 2
X-Cordelle-TimestampUnix seconds at which this attempt was sent — fresh on every retry.
X-Cordelle-Signaturev1=<hex>
User-AgentCordelle-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:

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\"" } }
StatusCodeWhen
400VALIDATIONA 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.
400UNFILLED_BLANKA message title or body still carries a ___ starter blank.
401UNAUTHORIZEDNo key, something that is not one, an unknown key, or a revoked key — three different messages.
403PLAN_FEATUREThe key’s agency is on Companion (or a lapsed plan) and the route is part of the full API.
403CARD_REQUIRED, PLAN_LIMITThe 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.
404NOT_FOUNDA departure, message or endpoint that is not this agency’s — never 403.
409CLIENT_KEY_REUSEDThe clientKey was already used to send to a different audience.
409GUIDE_AMBIGUOUS, GUIDE_ARCHIVED, GUIDE_REF_CONFLICTA guide entry could not be resolved to one active person. Nothing written.
413FST_ERR_CTP_BODY_TOO_LARGEThe body is over 1 MB (1,048,576 bytes). The HTTP layer’s code, not a Cordelle word.
415FST_ERR_CTP_INVALID_MEDIA_TYPEThe Content-Type is anything but application/json (except text/plain, which reaches the route and is refused as 400). Also the HTTP layer’s.
429RATE_LIMITEDOver 60 writes or 120 reads a minute for this key. retry-after says how long.
503INTEGRATIONS_UNCONFIGUREDRegistering a webhook on an install that cannot store secrets. Not the case in production.
500INTERNALOurs. Nothing about the cause is in the message.

Limits

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

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