webiOSNotesmacOSAndroidNotesCLIapi-test
REST + SSE + WebSocket · bearer auth · api.getcue.net

One API, Seven Clients

The web app, the iOS, Android and macOS apps, Cue Notes for iPhone and Android, and the CLI all speak the same surface: plain JSON over HTTP, one live stream for everything that changes, and a socket for writing notes together.

14
resource groups
7
client apps
2
live channels
0
SDKs required

Learn it once

Every endpoint follows the same handful of rules. Learn them from one request and the rest of the page is just filling in shapes.

Fig. 1 / Anatomy of a request
REQUEST1POST https://api.getcue.net/api/events2Authorization: Bearer eyJhbGciOiJIUzI1NiJ9…3Content-Type: application/json{"title": "Saturday hike","proposedTimes": [4{ "dateTime": "2026-06-13T09:00:00Z" }]}RESPONSE5201 Created{"id": 128,6"status": "open","myRole": "organizer"}1One base URLverb + path under /api2A bearer tokenexcept under /api/public3JSON both waysuploads go multipart4ISO-8601, in UTCno epoch millis, no offsets5The status first201 created · 204 empty6Lowercase enumsthe value is the contract
Creating an event. The six marks are the conventions every endpoint shares.
Base URL
Everything hangs off https://api.getcue.net, with routes under /api.
Auth
A bearer token on every call outside /api/public, bar three: the signed avatar links, whose signature is the credential; the notes socket, which opens with a single-use ticket minted under the bearer; and /api/widget, which takes only the widget’s own credential. Cue-issued, Google and Apple tokens are all accepted.
Content
JSON in, JSON out. Photo uploads are multipart/form-data; a video goes straight to storage.
Time
Every timestamp is an ISO-8601 instant in UTC, like 2026-06-08T18:30:00Z. A calendar day, such as a trip’s dates, is a plain 2026-09-11.
Enums
Lowercase or snake_case strings, parsed case-insensitively, never renamed once shipped.
Errors
One shape, { "error": "…" }, with a standard HTTP status.

14 doors, one key

The whole surface groups into 14 resources, each hanging off the same base URL and opened by the same bearer token — bar the iOS widget’s, which carries a key of its own. The schema behind them is its own tour.

Fig. 2 / The map
api.getcue.netone base URLAuth/api/public/authUsers/api/usersEvents/api/eventsDecisions/api/decisionsNotifications/api/notificationsReal-time/api/sseChat & DMs/api/chatChannels/api/channelsTrips/api/tripsNotes/api/notesMedia/api/photosSearch/api/searchDevices/api/devicesWidget/api/widget
Every group sits under one host. Tap one to jump to it.
01Auth/api/public/auth

The door without a key

Everything under /api/public answers without a token. Ask for a six-digit code by email, verify it, and trade the token you get back for a Cue session. Google and Apple sign-in hand the client a token of their own, which mints a session the same way — the API accepts all three issuers.

POST/api/public/auth/email/send-code
Email a six-digit code
POST/api/public/auth/email/verify
Exchange the code for a token
POST/api/auth/session
Trade any sign-in token for a session
POST/api/public/auth/refresh
Rotate the session
POST/api/public/auth/logout
End the sign-in, every token in its family
GET/api/public/invite/{token}
Preview an invite link
Fig. 3 / Signing in
clientcue-coreinboxPOST …/email/send-code{ email }a six-digit codeyou type it inPOST …/email/verify{ email, code }200 · { token }POST /api/auth/sessionthat token as bearer200 · access + refresh1 hour · 90 days
An email code becomes a session in three calls. Refreshing later rotates the pair.

A provider token works as a bearer on its own, but Apple’s expires in about ten minutes. So a client trades whatever it holds for a Cue session: a one-hour access token and a 90-day opaque refresh token. There is one mint endpoint rather than one per provider — whatever sign-in credential the API already accepts, POST /api/auth/session accepts. Only a Cue access token is refused, so a session can never re-mint itself. Each refresh rotates the pair. Replaying a spent refresh token whose successor is still unused is a lost reply and simply rotates again; once that successor has been spent, a replay revokes the whole family of refresh tokens descended from that sign-in.

POST/api/auth/session
{}
02Users/api/users

Who you are

Your account: your name, your chat color, your avatar — and an optional @handle that turns it into a public profile. A user appears the first time they sign in; there is no separate sign-up call.

GET/api/users/me
The signed-in profile
GET/api/users/me/level
Your own level and room
PUT/api/users/me/name
Set a display name
PUT/api/users/me/chat-color
Pick a chat color
PUT/api/users/me/handle
Claim an @handle
PUT/api/users/me/ai-descriptions
Keep your uploads from the AI, or not
GET/api/public/profiles/{handle}
A public profile, no token
GET/api/public/privacy
The privacy facts that hold now, no token
GET/api/users/search
Find people by name or @handle
DELETE/api/users/me
Delete the account
GET/api/users/me
{
  "id": 42,
  "name": "Ada",
  "email": "ada@example.com",
  "pictureUrl": null,
  "chatColor": "indigo",
  "bio": "Builds things. Walks a lot.",
  "handle": "ada",
  "profilePublic": true,
  "cover": null,
  "needsName": false,
  "linkedProviders": [
    "google",
    "email"
  ],
  "joinedAt": "2026-01-14T09:12:00Z",
  "aiDescriptions": true,
  "imageGeneration": null,
  "sendLinkPreviews": true,
  "showLinkPreviews": true
}

A user’s name color is one fixed enum, shared verbatim by the web, the native apps and the backend. Change it and every message you have sent takes the new color on the next fetch.

slatecoralamberoliveemeraldtealskyindigovioletmagenta

Every account carries an allowance on the two things that cost real money: stored bytes, and how many events, decisions and trips it starts in any rolling 30-day window. It rides a self-scoped endpoint and nothing else — no roster, profile or member list carries level data, so nobody can tell what level anyone else holds. slug and dimension are opaque and allowances is a list rather than fixed keys, so a new tier or dimension — generated_images, while image generation is on — lights up in shipped apps with no release. A non-null displayName is the badge.

{
  "slug": "established",
  "displayName": null,
  "blurb": null,
  "allowances": [
    {
      "dimension": "storage",
      "label": "Photos & video",
      "unit": "bytes",
      "allowance": 3221225472,
      "used": 431006720,
      "room": 2790218752,
      "resetsAt": null
    },
    {
      "dimension": "creations",
      "label": "New events, decisions and trips",
      "unit": "count",
      "allowance": 100,
      "used": 7,
      "room": 93,
      "resetsAt": "2026-08-27T09:14:00Z"
    }
  ]
}
03Events/api/events

Find a time

Propose a few times, invite people, and collect their replies in plain language. Gemini reads the thread and writes back a recommended slot, carried inside the event as resolution.

POST/api/events
Create an event
GET/api/events
List your events
GET/api/events/{id}
One event, in full
POST/api/events/{id}/respond
Reply in free text
POST/api/events/{id}/finalize
Lock in a time
PUT/api/events/{id}/invitations/{userId}/role
Let a guest help organize
POST/api/events
{
  "title": "Saturday hike",
  "description": "Bring water and good shoes.",
  "location": "Sognsvann",
  "proposedTimes": [
    {
      "dateTime": "2026-06-13T09:00:00Z"
    },
    {
      "dateTime": "2026-06-14T10:00:00Z"
    }
  ],
  "inviteeUserIds": [
    7,
    12
  ],
  "timeZone": "Europe/Oslo",
  "chatEnabled": true,
  "weatherEnabled": true,
  "latitude": 59.9764,
  "longitude": 10.7214
}
Fig. 4 / One lifecycle
POST · 201/finalize/reopen/cancel/reopenDELETE · 204opentaking repliesfinalizedlocked incanceledcalled offdeletedgone for good
Events and decisions share it. Finalize, cancel and reopen are POSTs to the resource’s own path, and only a canceled one can be deleted.

One read returns everything a client renders: the proposed slots, every invitation with its free-text reply, and the AI resolution with its per-slot scores (JSON, as a string) and a readable reason in the language most replies were written in.

{
  "id": 128,
  "title": "Saturday hike",
  "description": "Bring water and good shoes.",
  "location": "Sognsvann",
  "googleMapsUrl": null,
  "placeId": null,
  "status": "open",
  "finalizedTime": null,
  "timeZone": "Europe/Oslo",
  "createdBy": {
    "id": 42,
    "name": "Ada",
    "pictureUrl": null,
    "chatColor": "indigo",
    "handle": "ada"
  },
  "proposedTimes": [
    {
      "id": 901,
      "dateTime": "2026-06-13T09:00:00Z"
    },
    {
      "id": 902,
      "dateTime": "2026-06-14T10:00:00Z"
    }
  ],
  "invitations": [
    {
      "id": 511,
      "user": {
        "id": 7,
        "name": "Linnea",
        "pictureUrl": null,
        "chatColor": "coral",
        "handle": null
      },
      "status": "responded",
      "responseText": "Saturday is better for me",
      "respondedAt": "2026-06-08T18:30:00Z",
      "role": "participant"
    }
  ],
  "resolution": {
    "id": 77,
    "recommendedTime": {
      "id": 901,
      "dateTime": "2026-06-13T09:00:00Z"
    },
    "reasoning": "Saturday morning suits everyone who has replied so far.",
    "scores": "[{\"proposedTimeId\":901,\"score\":0.9,\"summary\":\"Suits everyone so far\"},{\"proposedTimeId\":902,\"score\":0.4,\"summary\":\"Linnea prefers Saturday\"}]",
    "resolvedAt": "2026-06-08T18:31:00Z",
    "status": "ok"
  },
  "createdAt": "2026-06-08T18:00:00Z",
  "updatedAt": "2026-06-08T18:31:00Z",
  "aiCommentatorType": null,
  "aiComment": null,
  "weatherEnabled": true,
  "weatherReport": {
    "id": 88,
    "report": "Saturday looks dry and mild — about 18°C with light cloud; bring a layer for the morning.",
    "forecastFetchedAt": "2026-06-08T18:25:00Z",
    "createdAt": "2026-06-08T18:25:30Z",
    "status": "ok"
  },
  "latitude": 59.9764,
  "longitude": 10.7214,
  "chatEnabled": true,
  "chatPreview": null,
  "cover": null,
  "containers": [
    {
      "kind": "trip",
      "id": 44,
      "title": "Lisbon, long weekend"
    }
  ],
  "myRole": "organizer"
}

status is one of openfinalizedcanceled and each invitation is pendingrespondeddeclined. Every invitation also carries a role — organizerparticipant — so the creator can share the organizing powers with anyone already invited, and myRole tells the client which one the viewer holds without re-deriving the rule. Two opt-in extras ride along on the event, next to the calls that grow its guest list:

PUT/api/events/{id}/ai-commentator
Persona: funny or serious
PUT/api/events/{id}/weather
Toggle the weather report
POST/api/events/{id}/invitations
Invite more people
POST/api/events/{id}/invite-link
Mint a shareable join link

Every AI result — resolution, aiComment, weatherReport and their decision and trip counterparts — carries a status: okrate_limitedfailed. Anything but ok holds an English placeholder, which a client swaps for its own line in the reader’s language.

04Decisions/api/decisions

Pick what

Events settle when; decisions settle what — where to travel, what to name the band. Propose options, everyone states a preference in plain language, and Gemini writes back a recommended pick with its reasoning, carried inside the decision as recommendation. The lifecycle is the event’s, transition for transition.

POST/api/decisions
Start a decision
GET/api/decisions
List your decisions
GET/api/decisions/{id}
One decision, in full
POST/api/decisions/{id}/options
Propose an option
POST/api/decisions/{id}/preference
State your preference
POST/api/decisions
{
  "question": "Where should we go this summer?",
  "context": "4 people, a long weekend, flying from Oslo.",
  "options": [
    "Lisbon",
    "Berlin",
    "Tromsø"
  ],
  "insightsEnabled": true,
  "chatEnabled": true
}

One read returns the whole picture: every option (each with its optional AI insight), every participant’s free-text preference, and the AI recommendation with per-option fit scores (JSON, as a string) and a readable reason.

{
  "id": 128,
  "question": "Where should we go this summer?",
  "context": "4 people, a long weekend, flying from Oslo.",
  "status": "open",
  "createdBy": {
    "id": 42,
    "name": "Ada",
    "pictureUrl": null,
    "chatColor": "indigo",
    "handle": "ada"
  },
  "finalizedOption": null,
  "options": [
    {
      "id": 301,
      "label": "Lisbon",
      "createdByUserId": 42,
      "createdAt": "2026-06-08T18:00:00Z",
      "insightRequestedAt": "2026-06-08T18:00:00Z",
      "insight": {
        "id": 51,
        "brief": "Warm, walkable, cheap direct flights from Oslo; can be crowded in July.",
        "sources": "[{\"title\":\"Visit Lisboa\",\"url\":\"https://www.visitlisboa.com\"}]",
        "searchQueries": "[\"Lisbon July weather\",\"Oslo Lisbon direct flights\"]",
        "searchEntryPoint": "<style>…</style><div class=\"container\">…</div>",
        "createdAt": "2026-06-08T18:02:00Z",
        "status": "ok"
      }
    },
    {
      "id": 302,
      "label": "Tromsø",
      "createdByUserId": 7,
      "createdAt": "2026-06-08T18:01:00Z",
      "insight": null,
      "insightRequestedAt": "2026-06-08T18:01:00Z"
    }
  ],
  "preferences": [
    {
      "id": 811,
      "user": {
        "id": 7,
        "name": "Linnea",
        "pictureUrl": null,
        "chatColor": "coral",
        "handle": null
      },
      "status": "stated",
      "preferenceText": "Somewhere warm, but Tromsø would be magical",
      "respondedAt": "2026-06-08T18:30:00Z",
      "role": "participant"
    }
  ],
  "recommendation": {
    "id": 61,
    "recommendedOption": {
      "id": 301,
      "label": "Lisbon",
      "createdByUserId": 42,
      "createdAt": "2026-06-08T18:00:00Z",
      "insight": null,
      "insightRequestedAt": "2026-06-08T18:00:00Z"
    },
    "reasoning": "Lisbon best fits the group's lean toward warm weather and cheap direct flights.",
    "scores": "[{\"optionId\":301,\"fit\":0.86,\"summary\":\"Warm and cheap to reach\"},{\"optionId\":302,\"fit\":0.42,\"summary\":\"Magical, but cold\"}]",
    "recommendedAt": "2026-06-08T18:31:00Z",
    "status": "ok"
  },
  "contextItems": [],
  "insightsEnabled": true,
  "aiCommentatorType": null,
  "aiComment": null,
  "chatEnabled": true,
  "chatPreview": null,
  "containers": [],
  "deadline": "2026-08-01T22:00:00Z",
  "createdAt": "2026-06-08T18:00:00Z",
  "updatedAt": "2026-06-08T18:31:00Z",
  "myRole": "organizer"
}

status is one of openfinalizedcanceled and each participant is pendingstateddeclined with a role of organizerparticipant. An optional deadline gives the group a decide-by moment; it is informational only, and nothing finalizes on its own. The AI extras opt in per decision, next to the calls that settle it or talk it through:

POST/api/decisions/{id}/options/suggest
AI brainstorms options
POST/api/decisions/{id}/context
Add context: text, or a PDF or image
PUT/api/decisions/{id}/insights
Toggle Option Insights
PUT/api/decisions/{id}/ai-commentator
Persona: funny or serious
POST/api/decisions/{id}/finalize
Lock in the pick
POST/api/decisions/{id}/chat/messages
Post in the decision chat
05Notifications/api/notifications

What you missed

The in-app nudges: an invitation landed, a time was locked in, someone @-mentioned you. List them, count the unread, and mark them read.

GET/api/notifications
Your notifications
GET/api/notifications/unread-count
Just the badge number
PUT/api/notifications/{id}/read
Mark one read
PUT/api/notifications/read-all
Clear the lot
GET/api/notifications
[
  {
    "id": 9001,
    "eventId": 128,
    "type": "invitation",
    "message": "Ada invited you to Saturday hike",
    "read": false,
    "createdAt": "2026-06-08T18:00:00Z",
    "muted": false,
    "decisionId": null,
    "tripId": null
  }
]

New kinds are added over time, so a client shows the ones it knows and quietly skips the rest.

invitationfinalizedreopenedcanceledevent_time_changedmentionorganizer_addeddecision_deadline_soondecision_deadline_reachedtrip_dates_changedtrip_started
06Real-time/api/sse

Signals, not payloads

One long-lived connection powers every live update on the web, iOS, Android, macOS and in the CLI. Open GET /api/sse/stream and the server pushes named events as things change. Each event is a signal, not a payload: it names what changed, and the client refetches over REST. Authorization stays in one place, and the stream stays trivial.

GET/api/sse/stream
text/event-stream, kept open
event: connected
data: {"status":"ok"}
event: event-updated
data: {"eventId":42,"changeType":"responded"}
event: heartbeat
data: {}
Fig. 5 / The stream, over time
SSE streamRESTdroppedretry in 1 s, then 2 sconnectedheartbeatevent-updatedGET /api/events/42heartbeatconnectedrefetch the screenheartbeat
A signal sends the client to REST for the data. Heartbeats every 20 seconds keep the pipe warm; after a drop the client backs off, reconnects and refetches what it shows.
Only after it’s durable

Signals fire after the transaction commits, never during it. Roll a write back and nothing is sent.

Across every replica

A replica holds only its own connections, so each signal goes through Valkey pub/sub and every replica replays it to its clients.

Heals itself

Reconnects back off from 1 s, doubling to a 30 s cap. A 401 refreshes the session and reconnects; only a failed refresh sends the client to sign in.

A handful of coarse event names carry a changeType that says exactly what moved.

connected{ "status": "ok" }
once, when the stream opens
event-updated{ "eventId", "changeType" }
created · responded · finalized · canceled · chat-message-added · resolution-updated · …
decision-updated{ "decisionId", "changeType" }
option-added · preference-updated · recommendation-updated · insight-updated · chat-message-added · …
trip-updated{ "tripId", "changeType" }
member-added · note-added · media-added · brief-updated · links-changed · chat-message-added · …
notification{ "unreadCount" }
the badge changed — refetch the list
conversation-updated{ "dmThreadId", "changeType" }
a direct-message thread moved
channel-updated{ "channelId", "changeType" }
a channel moved
media-ready{ "photoId", "status" }
a video finished transcoding
media-failed{ "photoId", "status" }
a video couldn’t be transcoded
image-session-updated{ "sessionId", "status" }
your cover studio moved a step — refetch the session
heartbeat{}
every 20 seconds

Why signals and not payloads? The full reasoning is in the architecture deep dive.

07Chat & DMs/api/chat

Five rooms, one inbox

Cue has five kinds of conversation: the chat on an event, 1:1 direct messages, channels, and the chats on decisions and trips. Each kind keeps its own endpoints on the same shape, and one inbox lists them all, sorted by activity.

GET/api/chat/conversations
The unified inbox
GET/api/events/{id}/chat/messages
Event chat history
POST/api/events/{id}/chat/messages
Post to an event chat
POST/api/chat/dm/{userId}/messages
Send a direct message
POST/api/chat/dm/{userId}/accept
Accept a message request
Fig. 6 / One inbox
Event chat/api/events/…DMs/api/chat/dm/…Channels/api/channels/…Decisions/api/decisions/…Trips/api/trips/…one inboxGET /api/chat/conversationsINBOXLisbon weekendnow#oslo2 minLinnea5 minSummer trip?1 hSaturday hike3 h
Every kind feeds the same list, newest first; a new message in any of them lights its row.
POST/api/events/128/chat/messages
{
  "text": "I'm in! 🥾",
  "photoId": null
}
POST/api/events/{id}/chat/messages/{mid}/reactions
Add an emoji
DELETE/api/events/{id}/chat/messages/{mid}/reactions/{emoji}
Remove it (idempotent)
PUT/api/events/{id}/chat/messages/{mid}
Edit within 10 minutes
POST/api/events/{id}/chat/read
Move your read cursor
POST/api/events/{id}/chat/typing
Say you’re typing
POST/api/events/{id}/chat/ai-participants
Add an AI to the room

An AI participant takes one of three interaction modes: proactivemention_onlyevent_driven.

Every message carries links: the URLs cue-core found in its text, each a start and end in UTF-16 offsets with the url a tap opens, so no client detects links itself. The first link in a person’s message may carry a linkPreview, built from a snapshot the server took of the page: at once if it has one, or seconds later when its first fetch lands. No client ever fetches the page: image and iconUrl are Cue’s own paths, answering 302 to a short-lived storage URL. A link to Cue itself adds a cue object instead, drawn for whoever is reading.

POST/api/chat/link-preview
The card a draft would post with, or null
DELETE/api/events/{id}/chat/messages/{mid}/link-preview
Take your card down, for good
GET/api/link-previews/{id}/image
302 to the thumbnail; /icon to the favicon
PUT/api/users/me/send-link-previews
Let Cue fetch the links you send, or not
PUT/api/users/me/show-link-previews
See cards on messages, or not
POST/api/chat/link-preview
{
  "text": "Saturday looks dry: https://www.yr.no/en/forecast/daily-table/1-72837"
}

layout is compactmedia, cue.kind is eventdecisiontripchannelnoteprofile and cue.access is memberpreviewnone; past the kind and access, a card carries only what its tier allows. The composer resolves the draft on each pause in typing, so a post arrives with its card; resolving is limited to 60 a minute per person, and a post sends linkPreview: false when the sender dismissed the chip. Every kind takes a card down the same way, on its own message path; the answer is the message, and the change rides that kind’s *-message-edited signal without marking it edited.

08Channels/api/channels

Rooms of their own

Standalone group chats like #oslo, tied to no event. They reuse the whole chat toolkit, add what a public room needs — membership, roles and three visibility tiers — and double as containers, gathering events and decisions the way a trip does.

POST/api/channels
Create a channel
GET/api/channels/discover
Search the directory
POST/api/channels/{id}/join
Join a public channel
POST/api/channels/{id}/messages
Post a message
POST/api/channels
{
  "name": "oslo",
  "description": "Everything Oslo",
  "visibility": "public"
}

visibility is publicprivatesecret and each member holds a role of owneradminmember. A private channel gates entry behind a request that a member approves.

POST/api/channels/{id}/requests
Ask to join a private channel
POST/api/channels/{id}/requests/{rid}/approve
Approve a request
PUT/api/channels/{id}/members/{userId}/role
Promote or demote
POST/api/channels/{id}/transfer
Hand over ownership
09Trips/api/trips

A container, not a question

A trip settles nothing — it organizes. It’s a container with its own roster, chat, notes, documents and album, gathering events and decisions into one timeline. Dates are optional and may be in the past, so the same resource is a plan for next month and a memory from years ago.

POST/api/trips
Start a trip
GET/api/trips
List your trips
GET/api/trips/{id}
One trip, in full
POST/api/trips/{id}/notes
Write a note
POST/api/trips/{id}/media
Add to the album
POST/api/trips
{
  "title": "Lisbon, long weekend",
  "destination": "Lisbon",
  "destinationLatitude": 38.7223,
  "destinationLongitude": -9.1393,
  "startDate": "2026-09-11",
  "endDate": "2026-09-14",
  "chatEnabled": true,
  "travelAiEnabled": true,
  "aiCommentatorType": "funny"
}

One read returns the container and everything hanging off it: the roster with roles, the linked pillars in itinerary order, the notes and documents, and the Travel AI brief when it’s switched on. The album is paged separately; only its count rides here. A destination picked as a place carries its coordinates, and a live trip’s brief reads the forecast there.

{
  "id": 44,
  "title": "Lisbon, long weekend",
  "description": "Four of us, flying from Oslo.",
  "destination": "Lisbon",
  "destinationLatitude": 38.7223,
  "destinationLongitude": -9.1393,
  "startDate": "2026-09-11",
  "endDate": "2026-09-14",
  "cover": null,
  "members": [
    {
      "id": 210,
      "user": {
        "id": 42,
        "name": "Ada",
        "pictureUrl": null,
        "chatColor": "indigo",
        "handle": "ada"
      },
      "role": "guide",
      "joinedAt": "2026-06-08T18:00:00Z"
    },
    {
      "id": 211,
      "user": {
        "id": 7,
        "name": "Linnea",
        "pictureUrl": null,
        "chatColor": "coral",
        "handle": null
      },
      "role": "traveler",
      "joinedAt": "2026-06-08T18:04:00Z"
    }
  ],
  "links": [
    {
      "linkId": 91,
      "linkedBy": {
        "id": 42,
        "name": "Ada",
        "pictureUrl": null,
        "chatColor": "indigo",
        "handle": "ada"
      },
      "linkedAt": "2026-06-08T18:10:00Z",
      "pillar": {
        "kind": "event",
        "id": 131,
        "title": "Flight out",
        "when": "2026-09-11T06:40:00Z",
        "status": "finalized"
      },
      "access": "member"
    },
    {
      "linkId": 92,
      "linkedBy": {
        "id": 7,
        "name": "Linnea",
        "pictureUrl": null,
        "chatColor": "coral",
        "handle": null
      },
      "linkedAt": "2026-06-08T18:12:00Z",
      "pillar": {
        "kind": "decision",
        "id": 305,
        "title": "Where do we stay?",
        "status": null,
        "deadline": "2026-08-01T22:00:00Z"
      },
      "access": "none"
    }
  ],
  "notes": [
    {
      "id": 55,
      "title": "Packing",
      "body": "Light layers — it's still warm in September.",
      "noteDate": null,
      "author": {
        "id": 42,
        "name": "Ada",
        "pictureUrl": null,
        "chatColor": "indigo",
        "handle": "ada"
      },
      "createdAt": "2026-06-08T18:20:00Z",
      "updatedAt": "2026-06-08T18:20:00Z"
    }
  ],
  "documents": [],
  "mediaCount": 0,
  "chatEnabled": true,
  "chatPreview": null,
  "travelAiEnabled": true,
  "travelBrief": {
    "id": 12,
    "brief": "Four of you land in Lisbon on the 11th…",
    "createdAt": "2026-06-08T18:21:00Z",
    "status": "ok"
  },
  "aiCommentatorType": "funny",
  "aiComment": null,
  "sourceChannel": null,
  "createdAt": "2026-06-08T18:00:00Z",
  "updatedAt": "2026-06-08T18:21:00Z"
}

A link is a soft tag, never ownership or access. The wire never says “trip” where it means container: containerKind is tripchannel and pillarKind is eventdecision, both plain strings a client skips when it meets one it doesn’t know. Each row carries the viewer’s access — membernone — and none stops at kind, title and date: the discoverable card. Trip roles are guidetraveler.

POST/api/events/{id}/links
Put an event in a container
GET/api/trips/{id}/links
The itinerary rows
DELETE/api/trips/{id}/links/{linkId}
Unlink (deletes nothing else)
POST/api/channels/{id}/spawn-trip
Seed a trip from a channel roster
PUT/api/trips/{id}/travel-ai
Toggle the Travel AI brief
10Notes/api/notes

Written together, live

Cue Notes are shared markdown, written live by everyone on them. A note’s truth is an Automerge document the server holds as a peer: clients that carry Automerge sync it over a WebSocket, and anything that doesn’t — a script, the CLI — saves plain markdown and lets the server merge it three-way. A note you aren’t on answers 404, never 403.

POST/api/notes
Create a note
GET/api/notes
Your notes: pinned, then recent
GET/api/notes/{id}
Markdown, heads, role and roster
POST/api/notes/{id}/text-edit
Save markdown, merged three-way
POST/api/notes/live/ticket
A single-use ticket for the socket
GET/api/notes/live
The WebSocket, opened with the ticket
POST/api/notes/42/text-edit
{
  "baseHeads": [
    "9f2c41d0…"
  ],
  "markdown": "# Trip plan\n\n- [x] Book the ferry\n- [ ] Pack the charger"
}

One JSON text frame per message, Automerge sync messages riding base64 in data, one connection multiplexed across every note it joins. Say hello first, then join notes; each change the server has stored comes back as an ack naming heads at or beyond it, and carets travel as presence — code-point offsets at named heads, so no runtime has to read another’s cursor. Unknown frames and fields are ignored, never fatal.

→ {"type":"hello","protocol":1,"client":"cue-web"}
← {"type":"welcome","protocol":1,"session":"3fa1c2d4e5f60718","limits":{"maxFrameBytes":4194304,"keepaliveSeconds":30}}
→ {"type":"join","note":42}
← {"type":"joined","note":42,"role":"editor"}
↔ {"type":"sync","note":42,"data":"QgHXd2x8…"}
← {"type":"ack","note":42,"heads":["c5ad67ee…"]}
← {"type":"presence","note":42,"peers":[{"session":"77b0a9e8d7c6b5a4","user":7,"heads":["c5ad67ee…"],"caret":2,"idle":false}]}

The server may also send refusedroleremovedindexerror; an index frame is a signal to refetch the list, the way SSE works everywhere else. Close codes say what to do next: 4401 mint a new ticket, 4429 back off, 1012 reconnect at once to another replica.

heads names the document state the markdown was read at — the base a text-edit sends back. Each member’s role is ownereditorviewer, and the title is simply the note’s first line.

{
  "id": 42,
  "title": "Trip plan",
  "markdown": "# Trip plan\n\n- [x] Book the ferry\n- [ ] Pack the charger",
  "heads": [
    "c5ad67ee…"
  ],
  "role": "editor",
  "members": [
    {
      "user": {
        "id": 42,
        "name": "Ada",
        "pictureUrl": null,
        "chatColor": "indigo",
        "handle": "ada"
      },
      "role": "owner",
      "joinedAt": "2026-06-08T18:00:00Z"
    },
    {
      "user": {
        "id": 7,
        "name": "Linnea",
        "pictureUrl": null,
        "chatColor": "coral",
        "handle": null
      },
      "role": "editor",
      "joinedAt": "2026-06-08T18:05:00Z"
    }
  ],
  "createdAt": "2026-06-08T18:00:00Z",
  "updatedAt": "2026-06-08T18:40:00Z",
  "updatedBy": {
    "id": 7,
    "name": "Linnea",
    "pictureUrl": null,
    "chatColor": "coral",
    "handle": null
  },
  "pinned": true
}
POST/api/notes/{id}/members
Add an editor or a viewer
POST/api/notes/{id}/invite-link
The editor or viewer link
GET/api/notes/{id}/versions
The history, newest first
POST/api/notes/{id}/versions/{versionId}/restore
Restore, as a forward edit
POST/api/notes/{id}/clean-up
Ask Gemini for a Clean up
POST/api/notes/{id}/clean-up/{proposalId}/accept
Accept it, merged
POST/api/notes/{id}/images
Upload or attach an image
POST/api/notes/resolve
Turn Cue links into chips
DELETE/api/notes/{id}
Move it to Recently deleted

A version’s reason is editrestoreclean_up. A Clean up proposal is private to whoever asked until they accept it, and a refusal carries a code beside its sentence: rate_limitedai_limitunsafeunavailable.

11Media/api/photos

Bytes go around the API

One library per person for photos, short videos and documents. Upload once, attach anywhere — chat, direct messages, an event cover. Variant reads answer 302 with a short-lived signed URL, so the bytes never pass through the API. The photo endpoints return what a grid can draw; the files endpoints return everything. An image may carry an aiDescription: what the AI saw in it, alt text where there is no caption and never shown as visible text. Its origin is uploadedgenerated, and a client marks a generated one Made with AI.

POST/api/photos
Upload a photo (multipart)
GET/api/photos/{id}/variants/{spec}
Redirect to a signed URL
GET/api/users/me/photos
Your photos and videos, paged
GET/api/users/me/files
The whole library: filter, search, sort
POST/api/photos/video-ticket
Reserve a direct video upload
DELETE/api/photos/{id}
To the trash, 30 days to restore
POST/api/photos/{id}/restore
Bring it back from the trash
{
  "id": 5567,
  "blurhash": "L6Pj0^jE.AyE_3t7t7R**0o#DgR4",
  "width": 4032,
  "height": 3024,
  "previewUrl": "/api/photos/5567/variants/preview.avif",
  "displayUrl": "/api/photos/5567/variants/display.avif",
  "thumbUrl": "/api/photos/5567/variants/thumb.avif",
  "mediaKind": "image",
  "status": "ready",
  "durationMs": null,
  "videoUrl": null,
  "fileName": null,
  "sizeBytes": 2481152,
  "aiDescription": "Four friends on a sunny viewpoint above red rooftops and a river.",
  "origin": "uploaded"
}

A clip is too big to push through the API, so it goes straight to storage. Ask for a ticket, PUT the raw bytes to its signed URL, then finalize; an async worker transcodes and signals media-ready over SSE, or media-failed if it can’t. mediaKind is imagevideodocument and status is readyprocessingfailed. The full pipeline is drawn in the architecture tour.

POST/api/photos/video-ticket
{
  "contentLength": 18452310,
  "durationMsHint": 8200
}

Whoever may set an event’s or a trip’s cover can ask Cue to paint one. A session reads what the subject already says, asks at most one round of questions when that isn’t enough, and paints takes the requester alone can see until they keep one — which becomes an ordinary library photo and the cover. Each step signals image-session-updated; the client refetches the session. Every route answers 404 while image generation is off, which is when imageGeneration on /api/users/me is null.

POST/api/photos/generations
Start a session for a purpose and target
GET/api/photos/generations/{id}
The session: chips, idea, takes
POST/api/photos/generations/{id}/answers
Answer its question round
POST/api/photos/generations/{id}/paint
Paint a guided idea
POST/api/photos/generations/{id}/takes
Another take
POST/api/photos/generations/{id}/refine
Repaint a take, in words
POST/api/photos/generations/{id}/keep
Keep a take as the cover
DELETE/api/photos/generations/{id}
Abandon it
GET/api/photos/generations/{id}/takes/{takeId}/image
Redirect to the take
GET/api/users/me/look
Your look, with the style, mood and painter catalogs
PUT/api/users/me/look
Replace your look
POST/api/photos/generations
{
  "purpose": "event_cover",
  "targetId": 128,
  "canReview": true
}

purpose is event_covertrip_coverlook_preview and status moves through readingdirectingaskingreviewingpaintingreadyfailedblockedkeptabandoned — blocked when Google refused the picture, never retried. Painter, style and mood are opaque slugs from the server’s catalogs, so a new one needs no release; the image prompt and the model never cross the wire. A take that paints spends one of the account’s generated_images for 30 days — 409 no_room when none is left — and one of 20 an hour, past which it is a 429.

13Devices/api/devices

Where to ring

Register a device’s push token when a user signs in, so the backend can reach it through APNs or FCM — chosen by the token’s platform — and drop it on sign-out. A token lives as long as the sign-in that registered it, and tokens the push service rejects are pruned automatically. Two optional fields make a token app-aware: app — cuecue_notes — picks where an iOS push is addressed, and surfaces — cuenotes — says which pushes the app can open. A shipped app sends neither and registers as Cue.

PUT/api/devices/token
Register a push token
DELETE/api/devices/token
Unregister it
PUT/api/devices/token
{
  "token": "a1b2c3d4e5f6…",
  "platform": "ios"
}
14Widget/api/widget

A key that never turns

The iOS Chats widget is a device of its own. It runs outside the app and must never spend the app’s rotating session, so the app trades the session for a cwk_ credential that never rotates, works under /api/widget and nowhere else, and dies with the sign-in that minted it. With it the widget reads its own trimmed rail and registers the WidgetKit push token cue-core nudges it through.

POST/api/auth/widget-credential
Trade a session for the widget’s credential
GET/api/widget/chats
The rail, as the widget draws it
PUT/api/widget/push-token
Register the WidgetKit push token
DELETE/api/widget/credential
Revoke it, no session needed
GET/api/widget/chats
{
  "chats": [
    {
      "id": "dm-7",
      "kind": "dm",
      "title": "Linnea",
      "subtitle": "Linnea: Lisbon it is ☀️",
      "lastMessageAt": "2026-06-08T18:40:00Z",
      "unreadCount": 2,
      "muted": false,
      "dmStatus": "accepted",
      "dmIsInitiator": true,
      "lastMessageText": "Lisbon it is ☀️",
      "lastMessageMediaKind": null,
      "lastSenderName": "Linnea",
      "lastSenderIsAi": false,
      "lastSenderIsMe": false,
      "avatarKey": null,
      "avatarUrl": null
    }
  ]
}
15Errorsstatus codes

When it fails

Most failures come back as the same one-key object with a fitting status code. There is no envelope to unwrap: read error for a short human message and the status for the category. Two answers differ. A rejected token gets an empty body from the security layer, so there the status is the whole message; and the allowance refusals below carry numbers instead of prose.

{
  "error": "Event not found"
}
It worked
200OKthe request succeeded
201Createdsomething new exists: an event, a trip, a photo…
204No Contentdone, with nothing to return
Fix the request
400Bad Requesta field is missing or malformed
401Unauthorizedthe token is missing or expired
403Forbiddennot yours to touch
404Not Foundno such resource, or no such route
405Method Not Allowedright path, wrong verb
409Conflictit clashes with the current state
410Gonea trashed photo is past its 30-day window
413Payload Too Largethe upload is over the limit
422Unprocessable Contentwell-formed, but it can’t be done: an upload that never arrived, a Clean up refused by its guard
429Too Many Requestsyou hit a rate limit
Our fault
500Internal Server Errorsomething broke on our side
503Service Unavailablea service behind it failed, such as the model behind Clean up

Where a client can show something better than a sentence, the body carries numbers instead of prose. Both allowance refusals do: a code to switch on and the figures behind it, so each client words the message once, at its API seam, instead of in every form.

{
  "code": "quota_exceeded",
  "usedBytes": 3218000000,
  "quotaBytes": 3221225472
}
16Principles

What holds everywhere

Six rules run through every group. They are why a client written once keeps working as the surface grows.

Signal, not payload

SSE events name what changed, never the data itself. The client refetches over REST, so authorization lives in one place and the stream stays paper-thin.

Additive, forever

The iOS and Android apps ship through their stores and can’t be force-updated, so every endpoint is a stable contract. New fields arrive optional; nothing is renamed or removed.

One enum pattern

Every enum on the wire is lowercase or snake_case, parsed case-insensitively and never renamed once shipped. open, responded, mention_only: the value is the contract.

ISO-8601, always UTC

Every timestamp is an ISO-8601 instant in UTC. No epoch millis, no local offsets, nothing to special-case on the client.

Errors read the same

Failures read { "error": "…" } with a sensible status: one shape to handle, whether it’s a 400, a 404 or a 429. Where a client can do better than a sentence, a code and the numbers come instead.

Idempotent where it counts

Reactions, reads and mutes converge on a state, not a delta. Removing a reaction you never made still returns 200 with the current tally, so retries are safe.

Keep exploring

Built on this API

The web app, the native apps and the CLI are all clients of this same public API — none of them has a private door.

Built by one person and a very capable AI.