Sign in with Discord. You’ll get a key on this page.
Send Authorization: Bearer YOUR_API_KEY on every request except GET /api/v1.
60/min · 2,000/day per key. Over the cap: 429 with retry_after in seconds.
GET /api/v1
Name, docs URL, endpoint list. No key.
GET /api/v1/wallpapers
format=phone|desktop|ultrawide|superwide · sort=newest|oldest|hue|likes|downloads
Returns { "count", "wallpapers" }. Skip format for all. ultrawide includes superwide. superwide is only ≥3:1. sort=hue walks the wheel from red (hues near 360 sit next to 0); missing hue last.
GET /api/v1/wallpapers/{id}
One wallpaper. Id is the filename without extension, e.g. aug142026-2. Unknown id: 404.
GET /api/v1/latest
format=phone|desktop|ultrawide|superwide · default desktop
One wallpaper, plus requested (the format you asked for) and fallback. fallback is true only if nothing existed in that format, so this is the newest of any shape. Superwide when you asked for ultrawide is not a fallback.
GET /api/v1/works
color · tag · format=phone|desktop|ultrawide|superwide
Returns { "count", "works" }. Newest first. A work is one titled piece that has a public page. color and tag are slugs (purple, abstract). A basic color matches every shade under it; a shade like violet matches only that shade. format keeps works that have an image in that shape and drops the other sizes, including video. ultrawide includes superwide.
GET /api/v1/works/{id}
One work. Id is the slug, e.g. aurora-reprise, or a hue number, e.g. HUE-0001. Unknown id: 404.
GET /api/v1/collections
Returns { "count", "collections" }. Public collections only. Drafts are omitted, and a draft id is 404.
GET /api/v1/collections/{id}
One public collection. works is the curated order: { id, title, url, thumb }.
curl -H "Authorization: Bearer YOUR_API_KEY" \ https://perfecthue.com/api/v1/works/aurora-reprise
| id | filename without extension |
| filename | original file |
| url | full image, or the video file when media is video |
| thumb | 640px JPEG |
| thumb_mid | 1280px JPEG |
| width / height | pixels; either may be null |
| format | phone (portrait), desktop (landscape under 2:1), ultrawide (2:1–3:1), superwide (≥3:1), or null |
| media | image on /wallpapers. video only inside a work |
| hue | 0–360, or null |
| hex | from hue, or null |
| likes | |
| downloads | |
| hue_no | HUE-0001, or null |
| work | { id, title, url } for the catalog work, or null |
| date | YYYY-MM-DD |
| created | ISO 8601, Mountain Time |
| requested | /latest only - format you asked for |
| fallback | /latest only - true if nothing in that format existed |
| id | slug, e.g. aurora-reprise |
| title | |
| catalog_no | HUE-0001, or a range when the sizes are numbered in a row |
| date | YYYY-MM-DD |
| description | or null |
| url | work page |
| thumb / thumb_mid | cover image. Same pick as the work page: closest to 16:9, phone last |
| colors | basic colors: red, orange, yellow, green, blue, purple, pink |
| shades | the finer palette slugs those roll up from, e.g. violet, cyan |
| tags | slugs, e.g. abstract, landscape |
| variant_of | parent work slug, or null |
| variants | other pieces in that family: { id, title, url, thumb } |
| stream_url | list of livestream URLs, or null |
| likes / downloads | sum of this work’s files |
| files | wallpaper objects, plus a video when the work has one |
A whole day of your channel as JSON. Import it from your channel’s ⋯ menu, or send it to PUT /api/v1/tv/me.
{
"timezone": "America/Denver",
"repeat": true,
"schedule": [
{ "at": "18:00", "video": "https://youtu.be/XXXXXXXXXXX" },
{ "video": "https://youtu.be/YYYYYYYYYYY", "length": "12:30" },
{ "at": "20:00", "playlist": "https://www.youtube.com/playlist?list=PL…", "until": "23:00", "order": "shuffle" }
]
}
| timezone | for every time in the file, e.g. Europe/London. Default UTC |
| repeat | true plays the list again, back to back, until the day is full |
| schedule | the items, in order |
| video | a YouTube link or id, or a Twitch video or clip link |
| playlist | a YouTube playlist link or id |
| at | pins the start, "18:30" or "6:30 pm" |
| day | starts a day of a week, "Mon" or "Monday". Makes the channel loop a week |
| length | plays only this much: seconds, "12:30" or "1:02:00" |
| until | playlist only: stop filling at this time |
| count | playlist only: at most this many videos |
| order | playlist only: playlist, shuffle or newest |
| title | ignored; Download schedule adds it so the file reads well |
at. Later times count on from there, so "02:00" after "18:00" is the next morning.at is dead air. A video that runs past the next at is cut short.until, the next at or the end of the day, skipping videos too long for what’s left.warnings.day, each day plays like a file of its own. A file without days on a channel that loops a week goes on every day.GET /api/v1/tv/channels
Returns { "count", "channels" }, busiest first. Only channels with videos on them.
GET /api/v1/tv/channels/{slug}
timezone · default UTC
Returns { "channel", "schedule" }. The slug is the end of the channel’s address, e.g. cooking-channel. schedule is a channel file of its videos.
GET /api/v1/tv/me
timezone · default UTC
channel · slug or id of one of your channels · default your first
Your channel and its schedule. Both null when you don’t have one.
PUT /api/v1/tv/me
channel · as above
Body: a channel file. Replaces your videos and makes your channel if you don’t have one. Returns { "channel", "added", "removed", "filled", "warnings", "schedule" }. filled is seconds.
POST /api/v1/tv/check
Body: a channel file. What it would put on, without saving. Returns { "videos", "filled", "warnings", "schedule" }.
Checks and replaces: 20 an hour, shared with Import. Send the key in the header for these, not ?key=.
curl -X PUT \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ --data @channel.json \ https://perfecthue.com/api/v1/tv/me
| id | never changes |
| slug | changes when the channel is renamed |
| name | |
| description | or null |
| url | channel page |
| owner | name |
| collaborative | anyone signed in can add videos |
| banner / logo | image URLs, or null |
| videos | on the schedule, live shows included |
| watching | people on the page now |
| on_now | { title, live, youtube }, with twitch instead of youtube for anything from Twitch, or null for dead air |
| created | ISO 8601, UTC |
| 400 | Bad request |
| 401 | Unauthorized |
| 403 | Blocked or muted |
| 404 | Not found |
| 405 | Method not allowed |
| 413 | Channel file too big |
| 422 | Channel file can’t be used · error says why |
| 429 | Rate limit exceeded · retry_after |