API reference
Read your screens, content, schedules and analytics programmatically
Base URL and authentication · API keys are read-only · Scopes · Endpoints available to an API key · Pagination · When the server narrows your request · Errors · Rate limits · Webhooks · What we promise not to break · Quickstart
Base URL and authentication
Everything lives under /v1 on https://api.sheencast.com. Authenticate with the X-API-Key header:
curl -H "X-API-Key: $SHEENCAST_API_KEY" \
https://api.sheencast.com/v1/screens
A key looks like sk_live_ followed by 32 random characters. The full value is returned exactly twice in its life — when you create it, and when you regenerate it. Only a SHA-256 hash is stored, so a lost key cannot be recovered; regenerate it instead.
If X-API-Key is present it is the only credential considered. A bad key alongside a valid session token returns the API-key error rather than falling back — sending the header means “authenticate as this key”, not “try whatever works”.
API keys are read-only
No API key can write anything. Every create, update and delete in the API requires the admin or editor role, and a key is always treated as viewer. Writes are made from the dashboard.
This matters because the write scopes are real enough to mislead: screens:write, content:write, playlists:write and schedules:write can be granted to a key and will show on it, but unlock nothing. A request carrying them still gets:
HTTP/1.1 403 Forbidden
{"error": "Insufficient permissions"}
Note the shape: that is a role refusal, not a scope one, so it says “Insufficient permissions” rather than “Insufficient API key scope”. Keys created from Settings → API Keys are read-only by default and this never comes up.
Three further scopes exist but gate nothing at all — webhooks:manage and api-keys:manage (both areas are dashboard-only) and schedules:write. assets:read and assets:write are retired aliases kept for keys issued before a rename; do not use them.
Scopes
A key carries a list of scopes and a request needs any one of the scopes its endpoint accepts — the check is an OR, not an AND. Missing scope:
HTTP/1.1 403 Forbidden
{"error": "Insufficient API key scope",
"required_scopes": ["screens:read"],
"key_scopes": ["analytics:read"]}
The five read scopes below are what a new key gets by default, and are the only ones that do anything.
screens:read— screens and screen groupscontent:read— content items, types and searchplaylists:read— playlists and their itemsschedules:read— schedulesanalytics:read— analytics, events and incidents
Endpoints available to an API key
This is the complete list. Anything not here — creating content, editing schedules, managing webhooks or keys, inviting users, billing — requires a dashboard session.
screens:read
GET /v1/screens— Screens in the org. Paged: limit ≤ 100, default 50.GET /v1/screens/:id— One screen, minus the pairing secret.GET /v1/screen-groups— All groups. Not paged.GET /v1/screen-groups/:id— One group with its screens.
content:read
GET /v1/content— Content items. Paged: limit ≤ 100, default 50.GET /v1/content/:id— One content item.GET /v1/content/types— Active content-type registry. Not paged.GET /v1/content/search— Full-text search. limit ≤ 100; no offset.
playlists:read
GET /v1/playlists— Playlists with item counts. Paged.GET /v1/playlists/:id— One playlist with its items.
schedules:read
GET /v1/schedules— All schedules. Not paged — the whole set every time.GET /v1/schedules/:id— One schedule.
analytics:read
GET /v1/analytics/summary— Impression and video aggregates, plus paged top content.GET /v1/analytics/health— Device-health rows. Paged.GET /v1/analytics/health/history/:screenId— Newest 400 health samples for one screen.GET /v1/analytics/incidents— Offline-incident rollups per screen.GET /v1/events/play— Raw play events. limit ≤ 1000, default 100; no offset.GET /v1/events/stats— Content and screen tables behind the Analytics page.GET /v1/events/stats/export— CSV of those tables.GET /v1/events/daily— Daily play and error series.GET /v1/events/export— Raw play-event dump, CSV or JSON.GET /v1/events/uptime— Per-screen uptime percentages.
Two endpoints need no credential at all: GET /v1/version and GET /health. /v1/version returns four static fields and is not a reference of any kind:
{"api_version":"v1","min_supported_client":"1.0.0",
"manifest_schema":1,"server_version":"0.1.0"}
Pagination
Paging is not uniform. Four different conventions are in use and some endpoints do not page at all. Check the endpoint you are calling rather than generalising from another one.
Screens, content, playlists — limit (default 50, max 100) and offset, answering with a pagination object:
{"screens": [...],
"pagination": {"total": 128, "limit": 50, "offset": 0, "has_more": true}}
total can be null on screens and content, so treat it as optional. has_more is computed from the limit you asked for, not from the rows returned.
Analytics and events — a named object and no has_more: top_content_pagination on /analytics/summary, pagination on /analytics/health, rollups_pagination on /analytics/incidents, and both content_pagination and screens_pagination on /events/stats. Stop when a page returns fewer rows than you asked for.
Search, raw events, log-style lists — limit only. There is no offset, so you cannot page past the first N.
Schedules, screen groups and content types return the entire set on every call.
When the server narrows your request
A few parameters are bounded server-side, so the query that runs can be narrower than the one you sent. None of these are errors and none of them fail — which is exactly why each one reports itself in the response. Compare what you asked for against what came back rather than assuming they match.
The reporting window is capped at 30 days
On /v1/events/stats and /v1/events/export, a start_date further back than 30 days is moved forward to the 30-day floor, and one in the future is moved back to now. The cap bounds a scan on a shared database; it is not configurable per key.
The JSON response says so in period. start and end are the window actually queried, requested_start is what you sent when it differed, and clamped is "retention", "future" or null:
{"period": {"start": "2026-07-19T00:00:00.000Z",
"end": "2026-08-18T00:00:00.000Z",
"requested_start": "2026-01-01T00:00:00.000Z",
"clamped": "retention"},
"total_plays": 48120, ...}
CSV cannot carry that, so /v1/events/export?format=csv answers with X-Export-Start-Date and X-Export-Window-Clamped (retention, future, or false) alongside X-Export-Truncated. For a longer history, request successive 30-day windows and join them yourself.
Sorting is restricted to an allowlist
GET /v1/content accepts sort only from a fixed set of columns, and order only as asc or desc. Anything else falls back to created_at descending rather than being rejected — an unrecognised value has always been accepted here, and rejecting it now would break callers that rely on the fallback.
Because a fallback is otherwise invisible, the response carries the values actually used, together with the full accepted set:
{"content": [...],
"pagination": {...},
"sort": {"applied": "created_at",
"order": "desc",
"requested": "description",
"honored": false,
"sortable": ["name", "created_at", "updated_at", "content_type",
"content_subtype", "category", "default_duration"]}}
Check sort.honored. If it is false, the rows came back in created_at order regardless of what you asked for, and sort.sortable tells you what you can ask for instead.
A failed read is an error, not an empty fleet
GET /v1/analytics/health answers 500 with {"code": "HEALTH_READ_FAILED"} when it cannot read device health. It previously answered 200 with an empty list, which asserts that no screen has ever reported health — a fleet-wide claim the server had just failed to establish. A genuinely empty fleet still returns 200 with screens: [], so treat empty and failed as the distinct cases they are.
Errors
error is not always a string. Most endpoints return {"error": "some message"}, but a 404, 500, 410 or malformed-UUID 400 returns an object. String(body.error) yields "[object Object]" on those. Branch on the type.
// most endpoints
{"error": "Screen not found"}
// unmatched route, server error, retired endpoint, bad UUID
{"error": {"code": "NOT_FOUND", "message": "...", "details": {"path": "/v1/nope"}}}
Some responses add a flat code beside a string message — for example PLAN_LIMIT_REACHED and NO_ORG_MEMBERSHIP. Prefer code over matching on message text; messages are free to change, codes are not.
Status codes you should expect to handle:
- 400 — malformed input. A
screen_id,content_id,start_dateorend_datethat is not a UUID / ISO-8601 timestamp is rejected here rather than failing deeper in. The body names the parameter:{"error":{"code":"INVALID_FORMAT","details":{"parameter":"screen_id"}}}. Do not retry these — fix the value. - 401 — missing or rejected credential.
Invalid API keymeans the key did not match;Missing Authorization headermeans you sent noX-API-Keyat all and the request fell through to the session path. - 403 — authenticated but refused: wrong role (all writes), missing scope, or a plan limit.
- 404 — object not found, or an unmatched route (structured shape).
- 410 —
/v1/assets, retired. Use/v1/content. - 429 — rate limited. See below.
Every response carries X-Request-Id. Quote it if you need us to look something up.
Rate limits
Each key has its own limit — rate_limit_per_min, 120 by default, settable between 1 and 6000 — measured over a fixed 60-second window. The budget is shared across every endpoint the key touches rather than being per-path.
Two expensive endpoints carry an additional cap of 30 requests per minute per ORGANISATION, shared by every key you own: GET /v1/analytics/incidents and GET /v1/events/stats/export. Raising rate_limit_per_min on a key does not lift it, and a second key does not get its own allowance — so a fleet-wide poll of either endpoint should be scheduled, not parallelised.
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1754006400
Retry-After: 37
{"error": "API key rate limit exceeded for this key. Adjust rate_limit_per_min on the key or wait.",
"details": {"limit": 120, "window": 60, "resetAt": "2026-08-01T00:00:00.000Z"}}
A 429 carries Retry-After in seconds — the header most HTTP clients back off on automatically. X-RateLimit-Reset is a Unix timestamp in SECONDS, and details.resetAt is the same instant as ISO-8601. Any of the three is safe to use.
The analytics and events endpoints are not currently rate limited at the application layer, whatever the key’s rate_limit_per_min says. Do not build on that — treat every endpoint as limited and back off on 429.
Webhooks
Sheencast POSTs events to an endpoint you register in Settings → Webhooks. Registration is dashboard-only; receiving them needs no Sheencast credential. The URL must be https:// and must not resolve to a loopback or private address.
Every event, when it fires, and the data keys it carries
device.online— A screen heartbeats after 2+ min of silence.data: screen_id, app_version, previous_last_seendevice.offline— No heartbeat for 5 min.data: screen_id, screen_name, last_seen, threshold_msdevice.paired— A screen completes pairing.data: screen_id, user_agent, platformdevice.unpaired— A screen is unpaired.data: screen_idcontent.created— Content is created.data: content_id, name, content_type, content_subtypecontent.updated— Content is edited.data: content_idcontent.deleted— Content is soft-deleted.data: content_idasset.uploaded— Alias of content.created.data: same as content.createdasset.deleted— Alias of content.deleted.data: same as content.deletedschedule.activated— A schedule is CREATED.data: schedule_id, playlist_id, screen_id, group_id, start_at, end_atschedule.deactivated— A schedule is DELETED.data: schedule_id, playlist_id, screen_id, group_idbilling.plan.upgraded— Plan moves up.data: plan, previous_plan, stripe_subscription_id, status*billing.plan.downgraded— Plan moves down or is canceled.data: plan, previous_plan*, stripe_subscription_id, status*billing.invoice.created— Stripe issues an invoice.data: invoice_id, amount_due, currency, hosted_invoice_url, statuswebhook.dlq— A delivery exhausts all 12 attempts.data: delivery_id, webhook_id, event_type, attempts, error
* Fields marked with an asterisk are not always present — the billing events are emitted from more than one Stripe handler and their shapes differ slightly. Treat every field on those three as optional.
asset.uploaded and asset.deleted are retired aliases of content.created and content.deleted. Subscribing to both a canonical event and its alias produces two deliveries for one change, with different delivery ids and the same emitted_at.
Every live delivery has this envelope and these headers:
POST /your-endpoint
Content-Type: application/json
X-Webhook-Signature: <base64 HMAC-SHA256>
X-Webhook-Event: content.updated
X-Webhook-Timestamp: 2026-08-01T12:00:00.000Z
X-Webhook-Delivery-Id: 3f2a…
Idempotency-Key: webhook:3f2a…:0
{"event": "content.updated",
"org_id": "…",
"emitted_at": "2026-08-01T12:00:00.000Z",
"data": { … }}
Verifying the signature. HMAC-SHA256 over `${timestamp}.${rawBody}`, keyed with your whsec_… secret as plain UTF-8 bytes, encoded as standard base64. There is no sha256= or version prefix.
import crypto from 'node:crypto';
function verify(rawBody, headers, secret) {
const signed = `${headers['x-webhook-timestamp']}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret).update(signed).digest('base64');
const got = Buffer.from(headers['x-webhook-signature'] ?? '', 'utf8');
const want = Buffer.from(expected, 'utf8');
// Length check FIRST: timingSafeEqual throws RangeError on a length
// mismatch, and an exception here becomes a 5xx, which we retry.
if (got.length !== want.length) return false;
return crypto.timingSafeEqual(want, got);
}
Reject with 401, not by throwing. Any non-2xx is retried, so an unhandled exception on a bad signature costs you 12 more copies of the same request over about six hours.
Sign the raw request bytes. The envelope is stored as JSONB and re-serialized before sending, so key order is not preserved — verifying against a parsed-and-restringified body will fail intermittently.
A 4xx is retried exactly like a 5xx. Nothing inspects the status beyond “was it 2xx”, so answering 400 or 422 to a payload you cannot process still costs 12 deliveries over about six hours. There is no response code meaning “stop sending this” — accept with 2xx and discard on your side, or pause the webhook.
Delivery is at-least-once and unordered. Up to six deliveries are in flight at once and failures retry with jitter, so events can arrive out of order — sequence them yourself with emitted_at. Any 2xx counts as success; the body is ignored, though its first kilobyte is kept in the delivery log. Each attempt times out after 30 seconds.
Retries run at roughly 1m, 2m, 4m, 8m, 16m, 32m and then hourly, each ±25%, for 12 attempts in total — about six hours. After the last one the payload moves to the dead-letter queue, where it stays replayable, and a webhook.dlq event fires. A webhook that is itself subscribed to webhook.dlq does not generate a further DLQ event for its own failures, so there is no feedback loop.
For 24 hours after you rotate the signing secret, deliveries carry a second header, X-Webhook-Signature-Previous, computed with the old secret over the same timestamp and body. Accept either and you can rotate without dropping anything in flight. After the window the old key stops being signed with — so deploy the new secret inside it.
Deduplicate on X-Webhook-Delivery-Id, not Idempotency-Key — the latter contains the attempt number and changes on every retry. Note that a delivery replayed from the dead-letter queue arrives with a new id.
Replay protection is yours to enforce. The timestamp is bound into the signature, so a captured request cannot be re-signed with a fresh one, but Sheencast does not impose a freshness window. Rejecting anything older than about five minutes is a reasonable default.
The Send test button sends a different shape — event: "test", a timestamp field instead of emitted_at, and no delivery id. Make sure an exhaustive switch on event tolerates it.
What we promise not to break
Responses are append-only. We may add a field, add an optional request parameter with a safe default, or add a new endpoint at any time. We will not remove or rename a field, change its type or meaning, change a status code, or tighten validation on an existing request without moving through a compatible intermediate state first.
So: ignore fields you do not recognize, and tolerate unfamiliar enum values rather than rejecting them. New enum members are considered a compatible change.
Three limits already in force predate that promise rather than following from it: the 30-day window, the sort allowlist, and /v1/analytics/health answering 500 where it once answered 200. All three are described under When the server narrows your request, and each reports itself in the response. We are not planning further narrowings, and any we do make will be announced and reported the same way.
Quickstart
List your screens and print the ones that are offline:
curl -s -H "X-API-Key: $SHEENCAST_API_KEY" \
"https://api.sheencast.com/v1/screens?limit=100" \
| jq -r '.screens[] | select(.status != "online") | "\(.name)\t\(.status)"'
Downtime per screen over the last 30 days:
curl -s -H "X-API-Key: $SHEENCAST_API_KEY" \
"https://api.sheencast.com/v1/analytics/incidents?days=30" \
| jq -r '.rollups[] | "\(.screen_name)\t\(.total_downtime_ms/3600000 | floor)h"'
Page through content, stopping when the API says there is no more:
offset=0
while :; do
page=$(curl -s -H "X-API-Key: $SHEENCAST_API_KEY" \
"https://api.sheencast.com/v1/content?limit=100&offset=$offset")
echo "$page" | jq -r '.content[].name'
[ "$(echo "$page" | jq -r '.pagination.has_more')" = "true" ] || break
offset=$((offset + 100))
done