API Reference
cueyou API guide
cueyou turns an Android phone into a programmable, full-screen takeover surface. Your code POSTs to the cueyou API, the server pushes via FCM, and the phone takes over its own screen with a title, a markdown description, an optional source chip, and a palette driven by urgency.
This document covers the public REST API. There is also a tRPC surface at /trpc/* used by the Android app and dashboard — that is not part of the public contract.
What's new
- Rich HTML content — send an
htmlbody with an alert or prompt and it renders on the phone, with buttons that can answer a prompt, and an optionalfullscreenmode where your page is the entire screen. - Delivery modes, quiet hours and a timed pause — users now choose how cueyou may reach them, and can pause everything for 1, 2, 8 or 24 hours. Calls made while delivery is off still return
200; see Operational notes.
Base URL
https://api.cueyou.appAuthentication
Every request needs an API key in the Authorization header:
Authorization: Bearer att_xxxxxxxxxxxxxxxxxxxxxxxxKeys are minted in the dashboard. They start with att_ and are shown only once at creation — store them as secrets. A revoked or unknown key produces 401 {"error":"unauthorized"}. Firebase ID tokens are also accepted by the same handlers but are intended for the first-party app, not for integrations.
Endpoints at a glance
| Method | Path | Purpose | Blocking? |
|---|---|---|---|
| GET | /api/devices | List devices registered on the account | No |
| POST | /api/alert | Fire a screen-takeover alarm on a device | No |
| POST | /api/prompt | Ask the device a question and wait for an answer | Yes (long-poll) |
All bodies are JSON. All responses are JSON. All timestamps are ISO 8601 strings.
Rich HTML content
Alerts and prompts accept an optional html field. When present and allowed, the phone renders it inside the takeover instead of the Markdown description, so you can lay out a status card, a table, a chart made of CSS, or a set of custom buttons.
Availability: the API key must have Allow HTML content switched on. It is off by default and can only be changed by the account owner inside the cueyou app (an API key cannot change it). A request with html from a key that isn't allowed is rejected with 403 {"error":"html_not_allowed"} and nothing is sent.
| Field | Type | Required | Notes |
|---|---|---|---|
| html | string | no | 1–50,000 characters. Sent with POST /api/alert or POST /api/prompt. |
| fullscreen | boolean | no | Default false. Requires html — sending fullscreen: true without html is a 400. See Full-screen pages. |
description is still required and is always your fallback: it is shown on older app versions, if the phone is offline when the HTML is fetched, and if the web view can't be shown.
How it appears. The takeover shows a fixed header — "Custom content from <your key's label>" — above your HTML. The label comes from the API key's name in the dashboard, not from anything you send, so recipients can always tell where the content came from. Your page cannot cover or restyle that header.
What your page can and can't do
Your HTML runs in a locked-down, sandboxed web view:
| Allowed | Blocked |
|---|---|
| HTML and CSS (inline <style>) | Any network request: fetch, XMLHttpRequest, remote images/fonts/scripts/CSS |
| Inline JavaScript (<script>) | Navigation: links, window.open, redirects, <iframe>, <form> submission |
| Images, fonts and audio embedded as data: URIs | File and content access, camera, microphone, location |
| Buttons that answer the prompt (below) | Autoplaying media (needs a tap) |
Because remote resources are blocked, embed everything you need (use data: URIs for images) and keep the page small.
On the lock screen, JavaScript is switched off and a note "Unlock to use buttons" is shown. Static HTML and CSS still render. Scripts and buttons work once the user unlocks the phone.
Buttons that answer a prompt
Inside your page, call the cueyou message channel to answer the prompt. The value comes back to your API call exactly like a text or option answer (status: "answered", answer: <your value>).
<div style="font-family: sans-serif; padding: 16px">
<h2>Deploy v2.18.0 to production?</h2>
<p>3 services · 12 commits · all checks green</p>
<button onclick="answer('approve')">Approve</button>
<button onclick="answer('reject')">Reject</button>
</div>
<script>
function answer(value) {
// `cueyou` exists once the phone is unlocked and the bridge is available.
if (window.cueyou) {
cueyou.postMessage(JSON.stringify({ type: "answer", value: value }));
}
}
</script>Message format:
| Field | Type | Notes |
|---|---|---|
| type | string | Must be "answer". |
| value | string | Up to 1,000 characters. Becomes the answer returned to your /api/prompt call. |
Messages must be JSON and under 2 KB; anything else is ignored. On alerts (which take no answer) HTML renders normally and answer messages are ignored.
Full-screen pages (fullscreen)
Add "fullscreen": true (per message, next to html) and your page becomes the whole screen: edge-to-edge, with the status and navigation bars hidden, and nothing of cueyou's drawn around it — no title, source chip, option buttons, "Dismiss" button or "Custom content from …" header. Your page is responsible for everything the user sees, including its own close button.
Because nothing identifies the sender on screen, only switch Allow HTML content on for API keys you fully trust.
What still works, invisibly, so a full-screen page can never trap the user:
| Situation | Behaviour |
|---|---|
| A ringing or vibrating alert | The first touch anywhere silences it (and is not passed to your page). |
| The user swipes Back | The alert is dismissed / the prompt is dismissed. |
| Your page wants to close itself | Call cueyou.postMessage(JSON.stringify({ type: "dismiss" })) from a button's click handler. It only counts right after a real tap. |
| A prompt | Answer with the normal answer message (see above). A prompt is resolved once, whether by an answer or a dismissal. |
Layout notes:
- The page has no default padding (
margin: 0). Useenv(safe-area-inset-top),env(safe-area-inset-bottom)etc. to stay clear of camera cut-outs and gesture bars. - The background is transparent by default and shows the urgency colour; set your own
backgroundonhtml, bodyfor a designed look. - On the lock screen scripts are off (as for all HTML): static HTML and CSS still render, but buttons and
dismissonly work after unlock — the user can still swipe Back. You can detect this:window.cueyouis undefined when scripts can't reach the app. - If the page can't be shown (for example the phone is offline when it tries to fetch it), the normal cueyou takeover is shown instead, using your
description. - Older app versions ignore
fullscreenand show the normal layout.
curl https://api.cueyou.app/api/alert \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Build failed",
"description": "main is red: 3 tests failing",
"urgency": "critical",
"fullscreen": true,
"html": "<div style=\"min-height:100vh;display:flex;flex-direction:column;justify-content:center;align-items:center;background:#7f1d1d;color:#fff;font-family:sans-serif;text-align:center;padding:env(safe-area-inset-top) 24px env(safe-area-inset-bottom)\"><h1 style=\"font-size:48px;margin:0\">main is red</h1><p>3 tests failing</p><button style=\"margin-top:32px;padding:16px 32px;font-size:18px\" onclick=\"cueyou.postMessage(JSON.stringify({type:\\\"dismiss\\\"}))\">Got it</button></div>"
}'Example — prompt with HTML buttons
curl https://api.cueyou.app/api/prompt \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Deploy v2.18.0?",
"description": "Approve the production deploy (plain-text fallback)",
"source": "ci · deploy/production",
"html": "<h2>Deploy v2.18.0?</h2><button onclick=\"cueyou.postMessage(JSON.stringify({type:\\\"answer\\\",value:\\\"approve\\\"}))\">Approve</button> <button onclick=\"cueyou.postMessage(JSON.stringify({type:\\\"answer\\\",value:\\\"reject\\\"}))\">Reject</button>",
"timeoutMs": 60000
}' \
--max-time 70Example — alert with a styled status card
curl https://api.cueyou.app/api/alert \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Latency spike",
"description": "p99 latency 4.2s on /checkout",
"urgency": "critical",
"html": "<div style=\"font-family:sans-serif\"><h1 style=\"color:#ff5252;margin:0\">4.2s</h1><p>p99 latency on <b>/checkout</b></p></div>"
}'Errors added by html
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid_input","details":...} | html empty or longer than 50,000 characters, or fullscreen: true without html |
| 403 | {"error":"html_not_allowed"} | The API key does not have Allow HTML content enabled |
Tips
- Design for a dark, narrow phone screen; use relative units and avoid fixed widths.
- Don't rely on colours alone — recipients may see the content in bright sunlight or with a colour-vision difference.
- Keep the plain
descriptionmeaningful; it is what shows if the HTML can't.
Security and trust
What recipients see and control. HTML content always appears under a header naming the API key that sent it (except in fullscreen mode, where the page is the whole screen and nothing of cueyou's is shown — so only enable HTML for keys you fully trust), runs without network access, and can't navigate away or open other apps. Account owners decide, per API key, whether HTML is allowed at all. Treat your API keys as secrets: a leaked key that is allowed to send HTML could show misleading content on your users' phones, so revoke it immediately if it's exposed.
/api/devices
Returns the devices on the account that owns the API key. Use this to discover the deviceId you'll pass to /api/alert and /api/prompt.
Example
curl https://api.cueyou.app/api/devices \
-H "Authorization: Bearer $CUEYOU_API_KEY"Response — 200
{
"devices": [
{
"id": "65f3a1b2c4d5e6f7a8b9c0d1",
"name": "Pixel 9",
"platform": "ANDROID",
"lastSeenAt": "2026-05-07T09:14:02.000Z"
}
]
}/api/alert
Fire-and-forget. The server delivers an FCM push and the phone immediately takes over its screen. The HTTP call returns as soon as FCM accepts the push — it does not wait for the user to dismiss.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
| deviceId | string | no | Device id from GET /api/devices. Omit to fan out to every Android device on the account. |
| title | string | yes | 1–100 chars; shown large on the takeover screen |
| description | string | yes | 0–2000 chars, rendered as markdown. See Shared concepts. |
| urgency | string | no | "info" | "normal" | "critical". Default "normal". See Shared concepts. |
| source | string | no | ≤ 80 chars. Shown as a chip at the top of the takeover. |
| html | string | no | 1–50,000 chars of HTML rendered on the device. Requires Allow HTML content on the API key. See Rich HTML content. |
| fullscreen | boolean | no | true shows your html as the whole screen with no cueyou UI around it. Requires html. See Full-screen pages. |
Example
curl https://api.cueyou.app/api/alert \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Build failed",
"description": "main is red — last green was 12 minutes ago"
}'Example — critical, with markdown and source
curl https://api.cueyou.app/api/alert \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Production 5xx spike",
"description": "**p99 latency** doubled in the last 5m.\n\n- region: `us-east-1`\n- error rate: 8.4%\n\n[Open dashboard](https://grafana.example.com)",
"urgency": "critical",
"source": "datadog · synthetics"
}'Response — 200
{
"alertId": "f8b71c98-...",
"sentAt": "2026-05-07T09:14:02.123Z",
"delivered": true,
"deviceCount": 2,
"blockedCount": 0
}If the user has the device disabled in their account settings (or quiet hours, etc.), the call still returns 200 but with:
{ "alertId": "...", "sentAt": "...", "delivered": false, "reason": "blocked_by_settings" }Fan-out. When you omit deviceId, the alert is delivered to every Android device registered on the account. deviceCount is how many devices were targeted and blockedCount is how many of those were suppressed by account settings (disabled, quiet hours, muted scopes). An account with zero registered devices still returns 200 with "deviceCount": 0, "blockedCount": 0 — it is not an error.
Errors
| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"invalid_json"} | Body wasn't valid JSON |
| 400 | {"error":"invalid_input","details":...} | Schema validation failed |
| 401 | {"error":"unauthorized"} | Missing/invalid/revoked API key |
| 404 | {"error":"Device not found for this account"} | deviceId doesn't belong to this account |
| 403 | {"error":"html_not_allowed"} | html was sent by a key that isn't allowed to send HTML |
| 502 | {"error":"FCM rejected the push: ..."} | Upstream FCM failure |
| 500 | {"error":"internal_error"} | Anything else |
/api/prompt
Ask the device a question and block until the user answers, dismisses, or the timeout fires. The server holds the HTTP connection open until a terminal state is reached. Pick this when you actually need a decision back, not just attention.
There are two modes, selected by the shape of the request:
- Options mode — pass
options: (string | Option)[]. The device shows horizontally swipeable cards, one per option;answerwill be the picked option'svalue. Plain strings are still accepted as a shorthand and mixed arrays are fine. - Text mode — omit
options. The device shows a free-text field;answeris whatever the user typed (up to 1000 chars).
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
| deviceId | string | no | Device id from GET /api/devices. Omit to fan out to every Android device on the account. |
| title | string | yes | 1–100 chars |
| description | string | yes | 0–2000 chars, rendered as markdown. See Shared concepts. |
| urgency | string | no | "info" | "normal" | "critical". Default "normal". |
| source | string | no | ≤ 80 chars. Shown as a chip at the top of the takeover. |
| options | (string | Option)[] | no | 1–10 entries. Plain strings or Option objects (see below). Omit for text mode. |
| timeoutMs | number | no | 5,000–900,000 (5s–15m). Default 300000 (5m). |
| html | string | no | 1–50,000 chars of HTML rendered on the device. Requires Allow HTML content on the API key. See Rich HTML content. |
| fullscreen | boolean | no | true shows your html as the whole screen with no cueyou UI around it. Requires html. See Full-screen pages. |
The Option object
| Field | Type | Required | Notes |
|---|---|---|---|
| label | string | yes | 1–200 chars. The text shown on the card. |
| value | string | no | 1–100 chars. Returned as answer when picked. Defaults to label. |
| primary | boolean | no | Marks this option as the recommended path. Only the first primary: true is treated as primary. |
When you pass a plain string, it's equivalent to { label: s, value: s }. The single-option label cap is 200 chars.
Example — yes/no (plain strings, legacy shape)
curl https://api.cueyou.app/api/prompt \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Approve deploy?",
"description": "v2.18.0 to production",
"options": ["Yes","No"],
"timeoutMs": 60000
}' \
--max-time 70With plain strings, answer comes back as "Yes" or "No" verbatim.
Example — structured options with primary, urgency, and source
curl https://api.cueyou.app/api/prompt \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Run destructive migration?",
"description": "Drops `legacy_events` (≈ 4.2M rows). **Irreversible** without a restore.",
"urgency": "critical",
"source": "claude-code · pre-commit",
"options": [
{ "label": "Yes, run the migration now", "value": "yes", "primary": true },
{ "label": "No, abort", "value": "no" },
{ "label": "Show me the SQL first", "value": "dry-run" }
],
"timeoutMs": 120000
}' \
--max-time 130Long, descriptive labels keep the user oriented; short values keep your code clean.
Example — free text
curl https://api.cueyou.app/api/prompt \
-H "Authorization: Bearer $CUEYOU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Override reason?",
"description": "Type a short note for the audit log"
}' \
--max-time 310Response — 200
{
"promptId": "8d2c61aa-...",
"status": "answered",
"answer": "yes",
"sentAt": "2026-05-07T09:14:02.123Z",
"resolvedAt": "2026-05-07T09:14:11.456Z"
}status values:
| Value | Meaning | answer |
|---|---|---|
| answered | User picked an option / typed a reply | Picked option's value (or typed string in text mode) |
| dismissed | User actively dismissed the prompt | null |
| timeout | timeoutMs elapsed before any user action | null |
| blocked | Prompt suppressed by account settings; never shown to the user | null |
Errors
Same shape as /api/alert. The most likely ones are 401, 404, and 502.
| Status | Body | Cause |
|---|---|---|
| 403 | {"error":"html_not_allowed"} | html was sent by a key that isn't allowed to send HTML |
timeoutMs plus a few seconds of slack — otherwise your client will give up before the server resolves the prompt.Fan-out & first-responder-wins. When you omit deviceId, the prompt is sent to every Android device on the account and the first one to answer (or dismiss) wins — its response is what you get back. The other phones keep showing the prompt until the user clears it locally, but their input no longer affects the API call. If the account has no registered devices, the request returns 404 immediately rather than long-polling into a void.
Choosing alert vs. prompt
- Use
/api/alertwhen you only need to grab attention — a build failed, an error spiked, a long task finished. Returns immediately. - Use
/api/promptwhen you need a decision — gating a destructive operation, asking for an override, collecting a free-form note. Blocks until the user responds.
A common pattern is a Claude Code (or CI) hook that calls /api/alert on notifications and /api/prompt on PreToolUse for risky tools so the user can approve from their phone.
Operational notes
- The user controls delivery. In the app each user chooses one delivery mode — Disabled (drop everything), Muted (full-screen takeover, silent), Vibration (vibrates, no sound) or Sounds (rings) — and can override it per device or per API key. They can also set quiet hours (a daily window during which alerts are dropped) and pause everything for 1, 2, 8 or 24 hours. When delivery is switched off — Disabled, inside quiet hours, or during a pause — alerts come back with
delivered: false, reason: "blocked_by_settings"and prompts come back withstatus: "blocked". Muted and Vibration still deliver. In every case the call returns200— handle these as non-errors. - Idempotency. There is no idempotency key. Retrying a
5xxmay produce duplicate phone alarms. Prefer surfacing the failure to the caller over blind retries. - Rate limits. Not enforced server-side today. Treat the phone as the rate limiter — repeated alarms are noisy and will train the user to ignore them.
- API key hygiene. Each integration should have its own key labelled in the dashboard. Revoke on leak; the next request with that key will get
401. - Urgency is visual-only today.
urgencydrives the takeover palette but does not currently change the sound. Per-tier ringing is a known follow-up.