Cue
REST + SSE · Bearer auth · api.getcue.net

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.

12
resource groups
5
HTTP verbs
1
live stream
5
client apps

Learn it once

Every endpoint follows the same handful of rules. Get these, and the rest of the page is just filling in shapes.

Base URLEverything hangs off https://api.getcue.net, with routes under /api/*.
AuthA bearer token on every call: Authorization: Bearer <token>. Cue-issued, Google and Apple tokens are all accepted.
ContentJSON in, JSON out. Bodies are application/json; uploads are multipart/form-data.
TimeEvery timestamp is an ISO-8601 instant in UTC — 2026-06-08T18:30:00Z.
EnumsLowercase / snake_case strings, parsed case-insensitively, never renamed once shipped.
ErrorsA single shape — { "error": "…" } — with a standard HTTP status.

Anatomy of a request

POSThttps://api.getcue.net/api/events
Authorization: Bearer eyJhbGciOiJI…
Content-Type: application/json
{
  "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.

01Auth/api/public/auth

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.

POST/api/public/auth/email/send-code
POST/api/public/auth/email/verify
POST/api/auth/session
POST/api/public/auth/refresh
POST/api/public/auth/logout
GET/api/public/invite/{token}
POST/api/public/auth/email/verify
{
  "email": "[email protected]",
  "code": "402913"
}

Send the token in the Authorization header on every authenticated request. A missing or expired token gets a 401.

GET /api/users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…

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.

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

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.

GET/api/users/me
GET/api/users/me/level
PUT/api/users/me/name
PUT/api/users/me/chat-color
PUT/api/users/me/handle
GET/api/public/profiles/{handle}
GET/api/users/search
GET/api/users/me
{
  "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.

slatecoralamberoliveemeraldtealskyindigovioletmagenta

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"
    }
  ]
}
03Events/api/events

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.

POST/api/events
GET/api/events
GET/api/events/{id}
POST/api/events/{id}/respond
POST/api/events/{id}/finalize
PUT/api/events/{id}/invitations/{userId}/role
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
}

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 roleorganizerparticipant — 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:

PUT/api/events/{id}/ai-commentator
PUT/api/events/{id}/weather
POST/api/events/{id}/invitations
POST/api/events/{id}/invite-link
04Decisions/api/decisions

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

POST/api/decisions
GET/api/decisions
GET/api/decisions/{id}
POST/api/decisions/{id}/options
POST/api/decisions/{id}/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 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:

POST/api/decisions/{id}/options/suggest
PUT/api/decisions/{id}/insights
PUT/api/decisions/{id}/ai-commentator
POST/api/decisions/{id}/finalize
POST/api/decisions/{id}/chat/messages
05Notifications/api/notifications

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

GET/api/notifications
GET/api/notifications/unread-count
PUT/api/notifications/{id}/read
PUT/api/notifications/read-all
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
  }
]
invitationfinalizedreopenedcanceledevent_time_changedmentionorganizer_addeddecision_deadline_soondecision_deadline_reachedtrip_dates_changedtrip_started
06Real-time/api/sse

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

GET/api/sse/stream

On the wire

event: connected
data: {"status":"ok"}
event: event-updated
data: {"eventId":128,"changeType":"responded"}
event: notification
data: {"unreadCount":3}
event: heartbeat
data: {}

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.

requestafter it commitsto every replicasignal only → refetcha write landsPOST /…/respondcue-coreruns it in a DB transactionValkey relaysse:broadcast · pub/subcore-1local emitterscore-2local emittersyour open devicesevent: event-updatedclient refetchesGET /api/events/{id}
A guest’s reply, once its transaction commits, fans out through Valkey to every replica’s open connections — each client gets a tiny signal, then refetches the changed event over REST.

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" }
once, on open
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" }
badge changed — refetch the list
conversation-updated{ "dmThreadId", "changeType" }
a DM moved
channel-updated{ "channelId", "changeType" }
a channel moved
media-ready{ "photoId", "status" }
a video finished transcoding
heartbeat{}
every 20s

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

07Chat & DMs/api/chat

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.

GET/api/chat/conversations
GET/api/events/{id}/chat/messages
POST/api/events/{id}/chat/messages
POST/api/chat/dm/{userId}/messages
POST/api/events/128/chat/messages
{
  "text": "I'm in! 🥾",
  "photoId": null
}
POST/api/events/{id}/chat/messages/{mid}/reactions
DELETE/api/events/{id}/chat/messages/{mid}/reactions/{emoji}
PUT/api/events/{id}/chat/messages/{mid}
POST/api/events/{id}/chat/read
POST/api/events/{id}/chat/ai-participants

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

08Channels/api/channels

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.

POST/api/channels
GET/api/channels/discover
POST/api/channels/{id}/join
POST/api/channels/{id}/messages
POST/api/channels
{
  "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.

POST/api/channels/{id}/requests
POST/api/channels/{id}/requests/{rid}/approve
PUT/api/channels/{id}/members/{userId}/role
POST/api/channels/{id}/transfer
09Trips/api/trips

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
GET/api/trips
GET/api/trips/{id}
POST/api/trips/{id}/notes
POST/api/trips/{id}/media
POST/api/trips
{
  "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 accessmembernone — and none stops at kind, title and date: the discoverable card. Trip roles are guidetraveler.

POST/api/events/{id}/links
GET/api/trips/{id}/links
DELETE/api/trips/{id}/links/{linkId}
POST/api/channels/{id}/spawn-trip
PUT/api/trips/{id}/travel-ai
10Media/api/photos

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

POST/api/photos
GET/api/photos/{id}/variants/{spec}
GET/api/users/me/photos
GET/api/users/me/files
POST/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.

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

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.

PUT/api/devices/token
DELETE/api/devices/token
PUT/api/devices/token
{
  "token": "a1b2c3d4e5f6…",
  "platform": "ios"
}
13Errorsstatus codes

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"
}
200OKthe request succeeded
201Createda new event or channel was made
204No Contentsuccess with nothing to return
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 current state
410Gonethe invite or resource expired
413Payload Too Largethe upload is over the cap
429Too Many Requestsyou hit a rate limit
500Internal Server Errorsomething broke on our side

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.

12
resource groups
one base URL each
5
HTTP verbs
plain, REST-clean
1
bearer token
on every request
0
SDKs required
just HTTP + JSON

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.