One API, five clients
The web app, the iOS, Android and macOS apps and the CLI all speak the same surface — plain JSON over HTTP, one live stream for everything realtime. Scroll down and watch it unfold, resource by resource.
Learn it once
Every endpoint follows the same handful of rules. Get these, and the rest of the page is just filling in shapes.
https://api.getcue.net, with routes under /api/*.Authorization: Bearer <token>. Cue-issued, Google and Apple tokens are all accepted.application/json; uploads are multipart/form-data.2026-06-08T18:30:00Z.{ "error": "…" } — with a standard HTTP status.Anatomy of a request
{
"title": "Saturday hike",
"proposedTimes": [
{
"dateTime": "2026-06-13T09:00:00Z"
}
]
}The schema behind every resource is its own scroll-through tour — see the 63 tables.
Twelve doors, one key
The whole surface groups into twelve resources, each hanging off the same base URL and the same bearer token — tap any one to jump to it.
The only endpoints you can call without a token. Ask for a six-digit code by email, verify it, and get back a JWT to carry on every request after that. Google and Apple sign-in produce a token 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}{
"email": "[email protected]",
"code": "402913"
}Send the token in the Authorization header on every authenticated request. A missing or expired token gets a 401.
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 1-hour access token plus a 90-day opaque refresh token. There is one mint endpoint rather than one per provider — whatever the existing decoder accepts, POST /api/auth/session accepts. Each refresh rotates the pair, and replaying a consumed token revokes the whole chain descended from that sign-in.
{}Your account: who you are, your display name, your chat colour, your avatar — and an optional @handle that turns it into a world-readable profile card. 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/public/profiles/{handle}/api/users/search{
"id": 42,
"name": "Ada",
"email": "[email protected]",
"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"
}A user’s chosen name colour is one fixed enum, shared verbatim across web, iOS and the backend. Change it and all of your past messages recolour on the next fetch.
Every account carries an allowance on the two dimensions that cost real money: stored bytes, and how many events, decisions and trips it starts per rolling 30 days. It rides a self-scoped endpoint and nothing else — no roster, profile or member list ever carries level data, so no one 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 a third dimension lights up on already-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"
}
]
}The original idea, still at the core. Propose a few times, invite people, and collect their replies in plain language. Gemini reads the thread and writes back a recommended slot — carried right 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
}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 human-readable reason in the majority language of the replies.
{
"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"
},
"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"
},
"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},{\"proposedTimeId\":902,\"score\":0.4}]",
"resolvedAt": "2026-06-08T18:31:00Z"
},
"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"
},
"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 hand the organizing powers to 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 when enabled:
/api/events/{id}/ai-commentator/api/events/{id}/weather/api/events/{id}/invitations/api/events/{id}/invite-linkThe third pillar: settle what, not when — where to travel, what to name it. 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.
/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
}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 human-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"
},
"finalizedOption": null,
"options": [
{
"id": 301,
"label": "Lisbon",
"createdByUserId": 42,
"createdAt": "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\"]",
"createdAt": "2026-06-08T18:02:00Z"
}
},
{
"id": 302,
"label": "Tromsø",
"createdByUserId": 7,
"createdAt": "2026-06-08T18:01:00Z",
"insight": null
}
],
"preferences": [
{
"id": 811,
"user": {
"id": 7,
"name": "Linnea",
"pictureUrl": null,
"chatColor": "coral"
},
"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"
},
"reasoning": "Lisbon best fits the group's lean toward warm weather and cheap direct flights.",
"scores": "[{\"optionId\":301,\"fit\":0.86},{\"optionId\":302,\"fit\":0.42}]",
"recommendedAt": "2026-06-08T18:31:00Z"
},
"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 instant — informational only, nothing auto-finalizes. Optional AI extras opt in per decision:
/api/decisions/{id}/options/suggest/api/decisions/{id}/insights/api/decisions/{id}/ai-commentator/api/decisions/{id}/finalize/api/decisions/{id}/chat/messagesThe in-app nudges: an invite landed, a time was finalized, someone @-mentioned you. List them, count the unread, mark them read, or mute a noisy event.
/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
}
]invitationfinalizedreopenedcanceledevent_time_changedmentionorganizer_addeddecision_deadline_soondecision_deadline_reachedtrip_dates_changedtrip_startedOne long-lived connection powers every live update on web, iOS, Android, macOS and the CLI. Open GET /api/sse/stream and the server pushes named events as things change. The trick: each event is a signal, not a payload — it names what changed, and the client refetches over REST. Authorization stays in one place; the stream stays trivial.
/api/sse/streamOn the wire
How a change reaches you
Nothing about the stream is request/response. A write on one replica has to surface on every device watching — here’s the path it travels, end to end.
Only after the write is durable
Signals fire on afterCommit, never mid-transaction. Roll the write back and nothing is ever sent — no phantom updates, no races.
Across every replica
A backend instance only holds its own connections, so the signal goes out on a Valkey pub/sub channel (sse:broadcast) and every instance replays it to its local clients. Your phone on one replica sees a change made on another — and all of your open tabs and devices light up at once.
Stays alive, heals itself
A heartbeat every 20s keeps the pipe warm and reaps dead sockets; nginx grants the stream a 24-hour read window. If it drops, the client reconnects with exponential backoff (1s → 30s) — and a 401 stops the loop and routes to sign-in.
A handful of coarse event names carry a changeType that says precisely what moved. A heartbeat lands every 20 seconds to keep the connection warm.
connected{ "status": "ok" }event-updated{ "eventId", "changeType" }decision-updated{ "decisionId", "changeType" }trip-updated{ "tripId", "changeType" }notification{ "unreadCount" }conversation-updated{ "dmThreadId", "changeType" }channel-updated{ "channelId", "changeType" }media-ready{ "photoId", "status" }heartbeat{}Why signals and not payloads? The full reasoning is in the architecture deep-dive.
Two of the five kinds of conversation: the live chat bolted onto an event, and 1:1 direct messages. Decisions and trips grow the same room on the same shape. A unified inbox lists every conversation — all five kinds — sorted by activity.
/api/chat/conversations/api/events/{id}/chat/messages/api/events/{id}/chat/messages/api/chat/dm/{userId}/messages{
"text": "I'm in! 🥾",
"photoId": null
}/api/events/{id}/chat/messages/{mid}/reactions/api/events/{id}/chat/messages/{mid}/reactions/{emoji}/api/events/{id}/chat/messages/{mid}/api/events/{id}/chat/read/api/events/{id}/chat/ai-participantsAn AI participant takes one of three interaction modes: proactivemention_onlyevent_driven.
Standalone group chats like #oslo, not tied to any event. They reuse the whole chat toolkit and add what a public room needs — membership, roles and three visibility tiers — and they double as containers, organizing 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"
}visibility is publicprivatesecret and each member holds a role of owneradminmember. Private channels gate entry behind a request a member approves.
/api/channels/{id}/requests/api/channels/{id}/requests/{rid}/approve/api/channels/{id}/members/{userId}/role/api/channels/{id}/transferA 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",
"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 pages separately — only its count rides here.
{
"id": 44,
"title": "Lisbon, long weekend",
"description": "Four of us, flying from Oslo.",
"destination": "Lisbon",
"startDate": "2026-09-11",
"endDate": "2026-09-14",
"cover": null,
"members": [
{
"id": 210,
"user": {
"id": 42,
"name": "Ada",
"pictureUrl": null,
"chatColor": "indigo"
},
"role": "guide",
"joinedAt": "2026-06-08T18:00:00Z"
},
{
"id": 211,
"user": {
"id": 7,
"name": "Linnea",
"pictureUrl": null,
"chatColor": "coral"
},
"role": "traveler",
"joinedAt": "2026-06-08T18:04:00Z"
}
],
"links": [
{
"linkId": 91,
"linkedBy": {
"id": 42,
"name": "Ada",
"pictureUrl": null,
"chatColor": "indigo"
},
"linkedAt": "2026-06-08T18:10:00Z",
"pillar": {
"kind": "event",
"id": 128,
"title": "Flight out",
"when": "2026-09-11T06:40:00Z",
"status": "finalized"
},
"access": "member"
},
{
"linkId": 92,
"linkedBy": {
"id": 7,
"name": "Linnea",
"pictureUrl": null,
"chatColor": "coral"
},
"linkedAt": "2026-06-08T18:12:00Z",
"pillar": {
"kind": "decision",
"id": 301,
"title": "Where do we stay?",
"status": "open",
"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"
},
"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"
},
"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 can skip when it meets an unknown one. Each row carries the viewer’s access — membernone — and none stops at kind, title and date: the discoverable card. Trip roles are guidetraveler.
/api/events/{id}/links/api/trips/{id}/links/api/trips/{id}/links/{linkId}/api/channels/{id}/spawn-trip/api/trips/{id}/travel-aiOne library per person for photos, short videos and documents. Upload once, attach anywhere — chat, DMs, an event cover. Reads are a 302 redirect to a short-lived signed URL, so the bytes never round-trip through the API. The photo endpoints return what a grid can draw; the files endpoints return everything.
/api/photos/api/photos/{id}/variants/{spec}/api/users/me/photos/api/users/me/files/api/photos/video-ticket{
"id": 5567,
"blurhash": "L6Pj0^jE.AyE_3t7t7R**0o#DgR4",
"width": 4032,
"height": 3024,
"previewUrl": "https://api.getcue.net/api/photos/5567/variants/preview.avif",
"displayUrl": "https://api.getcue.net/api/photos/5567/variants/display.avif",
"thumbUrl": "https://api.getcue.net/api/photos/5567/variants/thumb.avif",
"mediaKind": "image",
"status": "ready",
"durationMs": null,
"videoUrl": null
}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 the signed URL, then finalize — an async worker transcodes and signals media-ready over SSE. mediaKind is imagevideodocument and status is readyprocessingfailed.
{
"contentLength": 18452310,
"durationMsHint": 8200
}One endpoint answers “where in my Cue is this?” across events, decisions, trips, channels, people, chat messages and your own library. Ranked, scoped to exactly what you could already browse, and cheap enough to call on every keystroke — it powers the ⌘K palette on 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"
}
}
]
}
]
}Groups come back in one fixed order — eventdecisiontripchannelusermessagephoto — with empty ones omitted, because a surface driven by muscle memory beats one that reshuffles. kind is a plain lowercase string rather than a wire enum: the set is open, and a client drops one it doesn’t recognize. Every hit shares one shape — required kind, id, title, plus optionals only some kinds carry (snippet, status, when, handle, imagePhotoId, mediaKind, sender, createdAt, conversation).
Search never outreaches browsing
Every kind is scoped to exactly the rows its list endpoint would return — your events, your trips, channels you’re in plus the public directory, your own photos, and the messages of conversations you can read. A secret channel keeps its messages to its members.
A message hit is (conversation, id)
The message kind spans all five chat kinds, so one answer can carry two different messages both numbered 12. Its conversation — { kind, id, title } — is the rest of the identity, and the address to navigate to. Every other kind is kind + id alone.
Built for every keystroke
A query under 2 characters returns 200 with empty groups rather than an error, and unknown kinds are ignored rather than rejected — a half-typed query is a normal state, not a mistake. Its own rate limit is 120 requests / minute per user.
The smallest group: register a device’s push token on login so the backend can reach it through APNs or FCM — routed by the token’s platform — and drop it on logout. Stale tokens are pruned automatically when the push service rejects them.
/api/devices/token/api/devices/token{
"token": "a1b2c3d4e5f6…",
"platform": "ios"
}Almost every failure comes back as the same one-key object with a fitting status code. No envelope, no nesting — read error for a short human message and the status for the category. A rejected token is the one silent case: the security layer answers it bodiless, so there the status is the whole message.
{
"error": "Event not found"
}Where a client can render 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 composes its own copy once at its API seam rather than in every form.
{
"code": "quota_exceeded",
"usedBytes": 3218000000,
"quotaBytes": 3221225472
}Rules that hold everywhere
Six conventions run through all twelve groups. 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 exactly one place and the stream stays paper-thin.
Additive, forever
The iOS app ships through the App Store and can't be force-updated, so every endpoint is a stable contract. New fields are added optional; nothing is renamed or removed.
One enum pattern
Every enum on the wire is lowercase / 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, like 2026-06-08T18:30:00Z. No epoch millis, no local offsets, no ambiguity to special-case on the client.
Errors read the same
Every failure is { "error": "…" } with a sensible status code. One shape to handle whether it's a 400, a 404, or a 429 — no envelope to unwrap.
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 aggregate — retries are safe.
That’s the surface
Twelve resources, one stream, one token. The system that serves all of this — the modules, the real-time relay, the AI, the deploy — is its own story, and the schema beneath it is another.