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.
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.
- 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 plain2026-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.
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.
/ api/ public/ auth/ email/ send-code/ api/ public/ auth/ email/ verify/ api/ auth/ session/ api/ public/ auth/ refresh/ api/ public/ auth/ logout/ api/ public/ invite/ {token}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.
/ api/ users/ me/ api/ users/ me/ level/ api/ users/ me/ name/ api/ users/ me/ chat-color/ api/ users/ me/ handle/ api/ users/ me/ ai-descriptions/ api/ public/ profiles/ {handle}/ api/ public/ privacy/ api/ users/ search/ 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
}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.
/ api/ events/ api/ events/ api/ events/ {id}/ api/ events/ {id}/ respond/ api/ events/ {id}/ finalize/ api/ events/ {id}/ invitations/ {userId}/ role{
"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
}{
"responseText": "Saturday is better for me"
}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.
/ api/ decisions/ api/ decisions/ api/ decisions/ {id}/ api/ decisions/ {id}/ options/ api/ decisions/ {id}/ preference{
"question": "Where should we go this summer?",
"context": "4 people, a long weekend, flying from Oslo.",
"options": [
"Lisbon",
"Berlin",
"Tromsø"
],
"insightsEnabled": true,
"chatEnabled": true
}{
"preferenceText": "Somewhere warm, but Tromsø would be magical"
}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.
/ api/ notifications/ api/ notifications/ unread-count/ api/ notifications/ {id}/ read/ api/ notifications/ read-all[
{
"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
}
]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.
/ api/ sse/ streamSignals fire after the transaction commits, never during it. Roll a write back and nothing is sent.
A replica holds only its own connections, so each signal goes through Valkey pub/sub and every replica replays it to its clients.
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.
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.
/ api/ chat/ conversations/ api/ events/ {id}/ chat/ messages/ api/ events/ {id}/ chat/ messages/ api/ chat/ dm/ {userId}/ messages/ api/ chat/ dm/ {userId}/ accept{
"text": "I'm in! 🥾",
"photoId": null
}{
"id": 7781,
"text": "I'm in! 🥾",
"senderUserId": 7,
"senderUserName": "Linnea",
"senderUserPictureUrl": null,
"senderUserChatColor": "coral",
"senderUserHandle": null,
"senderAiId": null,
"senderAiName": null,
"createdAt": "2026-06-08T18:32:00Z",
"reactions": [
{
"emoji": "👍",
"count": 2,
"reactedByMe": true,
"sampleNames": [
"Ada",
"Jonas"
]
}
],
"editedAt": null,
"editCount": 0,
"photo": null,
"mentions": [],
"replyTo": null,
"links": [],
"linkPreview": null
}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.
/ api/ channels/ api/ channels/ discover/ api/ channels/ {id}/ join/ api/ channels/ {id}/ messages{
"name": "oslo",
"description": "Everything Oslo",
"visibility": "public"
}{
"id": 31,
"name": "oslo",
"description": "Everything Oslo",
"visibility": "public",
"pictureUrl": null,
"ownerUserId": 42,
"createdByUserId": 42,
"createdAt": "2026-06-08T18:40:00Z",
"memberCount": 1,
"membershipState": "member",
"myRole": "owner",
"pendingRequestCount": 0,
"muted": false,
"unreadCount": 0,
"lastMessage": null,
"links": []
}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.
/ api/ trips/ api/ trips/ api/ trips/ {id}/ api/ trips/ {id}/ notes/ api/ trips/ {id}/ media{
"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"
}{
"containerKind": "trip",
"containerId": 44
}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.
/ api/ notes/ api/ notes/ api/ notes/ {id}/ api/ notes/ {id}/ text-edit/ api/ notes/ live/ ticket/ api/ notes/ live{
"baseHeads": [
"9f2c41d0…"
],
"markdown": "# Trip plan\n\n- [x] Book the ferry\n- [ ] Pack the charger"
}{
"heads": [
"c5ad67ee…"
],
"markdown": "# Trip plan\n\n- [x] Book the ferry\n- [ ] Pack the charger\n- [ ] Brunost"
}{}{
"ticket": "Zk3Q9…",
"url": "wss://api.getcue.net/api/notes/live"
}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.
/ api/ photos/ api/ photos/ {id}/ variants/ {spec}/ api/ users/ me/ photos/ api/ users/ me/ files/ api/ photos/ video-ticket/ api/ photos/ {id}/ api/ photos/ {id}/ restore{
"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"
}Where in my Cue is this?
One endpoint searches events, decisions, trips, channels, people, chat messages, your own library and, when asked, your notes. It is ranked, scoped to exactly what you could already browse, and cheap enough to call on every keystroke — it powers the ⌘K palette on the web and macOS, the search screens on iOS and Android, and cue search in the CLI.
/ api/ search?q=lisbon/ api/ search?q=lisbon&kinds=trip,message/ api/ search?q=lisbon&limit=20{
"groups": [
{
"kind": "trip",
"hits": [
{
"kind": "trip",
"id": 44,
"title": "Lisbon, long weekend",
"snippet": "…four of us, flying from Oslo…",
"when": "2026-09-11T00:00:00Z",
"imagePhotoId": 5567
}
]
},
{
"kind": "message",
"hits": [
{
"kind": "message",
"id": 3121,
"title": "Lisbon, long weekend",
"snippet": "…meet at the Lisbon airport bus…",
"sender": "Ada",
"createdAt": "2026-07-12T09:15:00Z",
"conversation": {
"kind": "trip",
"id": 44,
"title": "Lisbon, long weekend"
}
}
]
}
]
}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.
/ api/ devices/ token/ api/ devices/ token{
"token": "a1b2c3d4e5f6…",
"platform": "ios"
}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.
/ api/ auth/ widget-credential/ api/ widget/ chats/ api/ widget/ push-token/ api/ widget/ credential{
"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
}
]
}{
"token": "80f1c2…",
"widgets": [
"CueChatsWidget/systemMedium"
]
}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"
}What holds everywhere
Six rules run through every group. They are why a client written once keeps working as the surface grows.
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.
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.
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.
Every timestamp is an ISO-8601 instant in UTC. No epoch millis, no local offsets, nothing to special-case on the client.
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.
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.