Channels & schedules
Create a FAST channel, generate a schedule, edit it, pass the publish gate and publish.
A channel is a 24/7 linear FAST channel with its own clock, dayparts, content scope and rules. Ingenee builds its schedule in two steps: AI proposes the dayparts and blocks, then a deterministic packer fits titles to the clock, respecting each title's runtime, the repeat window, maturity and rights. A person-editable draft is then published through a blocking validation gate, and only the published version goes to your EPG and playout feeds.
Rules the server enforces
- The AI never writes timestamps. It plans dayparts; the packer places titles. A channel's AI guidance (
schedulingPrompt) shapes dayparts only. It cannot choose which titles air or in what order, and it isn't used at all when any daypart is tied to specific weekdays. - Nothing reaches air unreviewed.
publishruns the gate first and refuses with409 BLOCKEDwhile a blocker is open. - Hard rules are checked after generation. Maturity, watershed, rights and overlap problems surface as blockers or warnings; the AI can't override them.
- You schedule a version of a title, with its own runtime and rating.
- Locked and promoted slots survive regeneration. A locked slot is kept exactly; promotions (
FEATURE,STUNT,BOOST,QUEUED) are hard preferences in the next build.
Which clock?
Dates mean different clocks on different calls. generate's startDate, transmission from/to and fill-gaps with a
tz use the channel's own timezone. schedule/range from/to, and place and move (day + startMin, minutes after
midnight), use UTC. Timestamps in responses are ISO-8601 UTC.
Create a channel
curl -X POST https://dev.app.ingenee.ai/api/v1/channels \
-H "Authorization: Bearer $INGENEE_API_KEY" -H "content-type: application/json" \
-d '{
"name": "Ingenee Classic Cinema",
"archetype": "movie",
"timezone": "America/New_York",
"language": "en",
"dnaPrompt": "Golden-age Hollywood and postwar classics, warm and unhurried",
"schedulingPrompt": "Lighter fare in the morning, prestige drama in prime time",
"scheduleHorizon": 14,
"autoPublish": false,
"dayparts": [
{ "name": "Morning", "start": "06:00", "end": "12:00", "tone": "light, family-friendly", "rating": "PG" },
{ "name": "Prime", "start": "20:00", "end": "23:00", "tone": "prestige drama" }
]
}'{ "id": "f27a91c3-4b6d-4e08-a5d2-9c1e3b7f0a64" }name and dnaPrompt (the channel's character, up to 4,000 characters) are required. The rest are optional:
| Field | Notes |
|---|---|
archetype | movie (default), tv, mv (music video) or mixed. Decides which title types the channel draws on |
timezone | An IANA name such as Europe/Berlin. Without it the clock is derived from region (default US) |
scheduleHorizon | Days to schedule ahead, 1 to 90 (default 14) |
maxRepeatWindow | Hours before a title may repeat, 1 to 720 (default 72) |
primeTimeStart, primeTimeEnd | Hours (defaults 20 and 23) |
dayparts | Up to 12 of { name, start "HH:MM", end, tone, rating, days }. days is weekdays, 0 = Sunday |
schedulingPrompt | The AI guidance described above |
autoPublish | Default false. See Suggest a schedule and publish it |
scope | Which titles the channel may use: { "mode": "all" | "catalogs" | "tier" | "view" | "collections", "catalogIds": […] } |
distributors | Distributor keys to check readiness for. See Distribution readiness |
adConfig, fillerSources | See Slates & ads |
A channel gets a slug made from its name. The slug never changes when you rename the channel, so feed URLs stay stable. Your plan can limit the number of live channels (403 FORBIDDEN).
GET /v1/channels returns { channels: [...] } (add ?archived=1 for archived ones). GET /v1/channels/{id} returns
{ channel, health } with the full configuration in channel and channel.dna.
Change a channel's configuration
PATCH /v1/channels/{id} changes any create field. Settings that live in the channel's DNA go under dna and are merged,
so sending dna.dayparts doesn't reset dna.scope. adConfig is merged field by field too.
curl -X PATCH https://dev.app.ingenee.ai/api/v1/channels/f27a91c3-4b6d-4e08-a5d2-9c1e3b7f0a64 \
-H "Authorization: Bearer $INGENEE_API_KEY" -H "content-type: application/json" \
-d '{
"scheduleHorizon": 7,
"dna": {
"scheduling": { "mode": "fixed", "blockMin": 30 },
"scope": { "mode": "catalogs", "catalogIds": ["0e578f59-bf28-455f-bc82-f0639a7575fb"] },
"exclusions": ["horror"],
"watershed": 21
}
}'{ "id": "f27a91c3-4b6d-4e08-a5d2-9c1e3b7f0a64", "updated": ["scheduleHorizon", "dna"] }Useful dna keys:
| Key | Meaning |
|---|---|
scheduling | { "mode": "flexible" | "fixed", "blockMin": 15 | 30 | 60, "exceptions": […] }. Fixed starts programmes on block boundaries; flexible starts each when the last ends |
scope | As at create |
dayparts, tiers | Up to 12 each |
exclusions, includeGenres | Genre blacklist and whitelist |
familiarityTarget, freshnessTarget | 0 to 100 |
contentRatio | Mix by type, such as { "movie": 70, "series": 30 }. Every value at 0 is refused |
watershed | Hour (0 to 24) before which mature content may not air |
territories, territoryMode | Coverage, with territoryMode of all, include or exclude |
distributors | Distributor keys |
Also on the body: name (a clash with another channel is 409 CONFLICT), timezone (or null), language, dnaPrompt,
schedulingPrompt, streamUrl, status (idle, building, live, paused) and autoPublish.
POST /v1/channels/{id}/archive and /restore take a channel off and back into the live list.
Changing settings that shape a schedule (dayparts, ads, watershed, scope, horizon, scheduling mode) matters for auto-publish. See below.
Suggest a schedule and publish it
Generate
curl -X POST https://dev.app.ingenee.ai/api/v1/channels/f27a91c3-4b6d-4e08-a5d2-9c1e3b7f0a64/schedule/generate \
-H "Authorization: Bearer $INGENEE_API_KEY" -H "content-type: application/json" \
-d '{ "startDate": "2026-11-02" }'startDate is optional: a channel-local calendar date, YYYY-MM-DD, not in the past and at most 90 days ahead (otherwise
400 VALIDATION, naming the channel's own dates). The window to rebuild begins at local midnight on that date, and
everything the channel already airs outside it is carried into the new version unchanged. Without startDate the build
covers the channel's horizon from now, keeping what has already aired. A long horizon can take minutes, so allow a 5-minute
client timeout.
{
"versionId": "6b0e1d2c-3a4f-4b5c-8d6e-7f8091a2b3c4",
"versionNum": 7,
"itemCount": 412,
"itemsBuilt": 398,
"days": 14,
"distinctTitles": 188,
"window": { "start": "2026-11-02T05:00:00.000Z", "end": "2026-11-16T05:00:00.000Z" },
"published": false,
"changedFromPrevious": true,
"gapCount": 0,
"gapMinutes": 0,
"replayWarnings": [],
"poolWarnings": [],
"tierSplit": [],
"dayparts": [ { "name": "Prime", "startHour": 20, "endHour": 23, "concept": "prestige drama", "allowedRating": null, "days": null } ]
}The new build is saved as a new version. Read the warnings: replayWarnings (the pool is too small to honor the
repeat window), poolWarnings (a starved pool), gaps and gapMinutes (time nothing fit), eventWarnings, and
semanticRanking: false (built without AI similarity ranking). If nothing could be scheduled the call returns 422 with the
reason, for example an empty scope.
Does it publish itself?
- If the channel has
autoPublishon, your plan includes auto-publish, the build passes the publish gate, and the channel's settings haven't changed since the last published version, the build is published and the response says"published": true. - Otherwise the build waits as a draft (
"published": false). If auto-publish was blocked because settings changed, the response adds"autoPublishHeld": "settings changed since the published schedule", so a person reviews the effect of the new rules first.
Preview what you built
GET /v1/channels/{id}/schedule/simulate analyses the latest saved version without changing anything and returns
totals (airings, distinctTitles, hours, avgSlotMin), titlePlays, genreMix, daypartMix, timeline (per-day airings and hours)
and a projection. It takes no parameters. It reads the latest version, so call it after generate. With no schedule
yet it returns { "hasData": false }.
Check the gate
curl https://dev.app.ingenee.ai/api/v1/channels/f27a91c3-4b6d-4e08-a5d2-9c1e3b7f0a64/publish-gate \
-H "Authorization: Bearer $INGENEE_API_KEY"{
"blockers": [ "2 title(s) scheduled with expired rights — out of licence window: Captain Comet, Harbor Lights." ],
"warnings": [ "3 airing(s) repeat a title inside the 72h window." ],
"passing": ["Health OK — Familiarity 62 · Freshness 48"],
"destinations": [ { "name": "EPG (XMLTV)", "path": "/channels/…/epg.xml", "ready": true } ],
"counts": { "blockers": 1, "warnings": 1, "passing": 1 },
"canPublish": false,
"version": { "versionNum": 7, "itemCount": 412, "coveredDays": 14 }
}Blockers include: no schedule or an empty one, EPG coverage under 3 days, mature titles before the watershed, airings shorter than their content or overlapping, titles out of licence, off-air gaps, an invalid playout export, and a required slate with no playout asset ID. Warnings include rights expiring within 30 days, repeats inside the window, missing poster art and ad load over the cap.
Publish
curl -X POST https://dev.app.ingenee.ai/api/v1/channels/f27a91c3-4b6d-4e08-a5d2-9c1e3b7f0a64/publish \
-H "Authorization: Bearer $INGENEE_API_KEY"{ "id": "f27a91c3-4b6d-4e08-a5d2-9c1e3b7f0a64", "status": "live", "versionId": "6b0e1d2c-3a4f-4b5c-8d6e-7f8091a2b3c4" }{ "error": { "code": "BLOCKED", "message": "Cannot publish — 1 blocker(s) must be fixed first.", "details": {} } }Publishing makes the latest version the one your EPG and playout feeds serve, and sends the
publish-succeeded webhook. Like the Pages publish, the BLOCKED reply only counts blockers; call publish-gate
to read them.
Read the schedule
| Call | Returns |
|---|---|
GET /v1/channels/{id}/schedule | The latest version (which may be an unpublished draft): version, published (the live version number), adBreaks, and items |
GET /v1/channels/{id}/schedule/range?from=2026-11-02&to=2026-11-08 | The same shape for a date range (YYYY-MM-DD, UTC days, at most 40 days), with days showing which version supplied each day |
{
"id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"assetId": "1b478b6a-7721-4a94-aade-55c5658f259c",
"title": "The Long Night",
"startsAt": "2026-11-02T01:00:00.000Z",
"endsAt": "2026-11-02T02:48:00.000Z",
"slotRuntimeMin": 108,
"daypart": "Prime",
"blockLabel": null,
"isFiller": false,
"rating": "TV-14",
"lockId": null,
"breaks": [ { "kind": "mid", "sequence": 1, "durationSec": 120, "offsetMin": 45, "scteEventType": "SPLICE_INSERT" } ]
}A bad range returns 400 VALIDATION_FAILED. Days in the past come from the version that was current at the end of that day,
which is a reconstruction rather than a transmission record (publishLog: false).
Edit the schedule by hand
Edits apply to the latest version. Publish afterwards for them to reach your feeds. Every edit refuses to overlap or cut
a programme: a title is never trimmed to fit, and a clash returns 409 CONFLICT with a message saying by how much.
| Call | Body | Notes |
|---|---|---|
POST …/schedule/place | { "assetId", "day": "2026-11-02", "startMin": 1200, "episodeId"?, "durationMin"?, "adOverride"? } | Adds an airing at a UTC time (snapped to 5 minutes) and locks it. Needs a generated schedule first (409). The airing is at least the title's runtime plus its planned ad time. A repeat of the neighbouring title is allowed and returned as a warning |
POST …/schedule/item/move | { "itemId", "day", "startMin", "durationMin"?, "locked"? } | Moves an airing; locked: true pins it at the new time and false releases its lock |
POST …/schedule/item/remove | { "itemId" } | Removes an airing as a new draft version. { removed, undoable, versionNum } |
POST …/schedule/lock | { "itemId" } | Pins an airing. The next generate keeps it. { lockId } |
POST …/schedule/fill-gaps | { "day": "2026-11-02", "tz"?: "America/New_York" } | Fills only the empty time on that day, in place, with filler that respects exclusions, scope and rating. { itemsAdded, minutesFilled, gaps }. day is a UTC day unless you send tz |
POST …/schedule/clear | { "kind": "range", "from", "to" } or { "kind": "items", "itemIds": […] } | Removes airings that start in the range (ISO timestamps) as a new draft. Aired time and locked airings are kept unless includeLocked: true. Send dryRun: true to count first. { removed, keptLocked, keptAired, versionNum, undoable } |
POST …/schedule/extend | { "days": 7 } | Adds 1 to 90 days after the end of the latest version. A version can span at most 90 days |
POST …/schedule/undo | none | Drops the last hand edit made by remove or clear, restoring the previous version exactly. 409 if the latest version is a full regenerate or the base schedule |
All of these are under /v1/channels/{id}/. The place response is
{ itemId, title, startMin, dur, episode, warning } with status 201.
Versions and rollback
curl https://dev.app.ingenee.ai/api/v1/channels/f27a91c3-4b6d-4e08-a5d2-9c1e3b7f0a64/versions \
-H "Authorization: Bearer $INGENEE_API_KEY"{
"channel": { "id": "f27a91c3-…", "name": "Ingenee Classic Cinema" },
"liveVersionId": "5c9d8e7f-…",
"publishedAt": "2026-10-29T18:36:29.063Z",
"versions": [
{ "id": "6b0e1d2c-…", "versionNum": 7, "itemCount": 412, "modelId": "…", "generatedAt": "…", "horizonDays": 14, "isLive": false },
{ "id": "5c9d8e7f-…", "versionNum": 6, "itemCount": 405, "modelId": "…", "generatedAt": "…", "horizonDays": 14, "isLive": true }
]
}The list holds the latest 40 versions of any kind, with the live one marked. POST /v1/channels/{id}/versions/{vid}/restore
copies a version's airings into a new draft version; it returns 201 with { versionId, versionNum, restoredFrom, itemCount }.
The channel's status becomes building, and the restored version must pass the publish gate again before it is live.
Blocks and promotions
GET /v1/channels/{id}/blocks lists the channel's programming blocks.
A promotion tells the next build to prefer a title, series, collection or genre in a date window:
curl -X POST https://dev.app.ingenee.ai/api/v1/channels/f27a91c3-4b6d-4e08-a5d2-9c1e3b7f0a64/promotions \
-H "Authorization: Bearer $INGENEE_API_KEY" -H "content-type: application/json" \
-d '{
"kind": "feature",
"targetKind": "title",
"targetRef": "1b478b6a-7721-4a94-aade-55c5658f259c",
"startsOn": "2026-11-06", "endsOn": "2026-11-06",
"priority": "high",
"params": { "feature": { "timeMode": "exact", "start": "21:00" } }
}'201 with { "id": "…" }. kind is feature, stunt, boost or queued, and each needs its own params key: feature
(timeMode exact or best, start), stunt (start, lengthMin 15 to 1440), boost (tier Heavy or Medium), queued
(withinDays 1 to 90). A feature or queued promotion can target a title or series; stunt also a collection;
boost also a genre (targetRef is the genre name). Optional: days (0 = Sunday), daypartId (the name of one of the channel's dayparts),
priority (high, normal, low), epgLabel (none, new, premiere, season_finale, marathon), notes. The reply to
GET …/promotions is { channel, promotions, counts }. A viewer key can read but not add.
Distribution readiness
The distributors you tick on a channel are a list of keys in dna.distributors, set with PATCH. They don't create a per-channel
delivery target. What they give you is a readiness check:
curl https://dev.app.ingenee.ai/api/v1/channels/f27a91c3-4b6d-4e08-a5d2-9c1e3b7f0a64/distribution \
-H "Authorization: Bearer $INGENEE_API_KEY"{
"channel": { "id": "f27a91c3-…", "name": "Ingenee Classic Cinema", "slug": "ingenee-classic-cinema" },
"scheduledTitles": 188,
"partners": [
{ "key": "roku_dpf", "name": "Roku Direct Publisher Feed", "selected": true, "entitled": true, "blockedTitles": 4,
"requirements": ["IMDb id required", "16:9 hero ≥ 1280×720", "Runtime present"],
"rejections": [ { "code": "…", "message": "…", "titles": ["…"] } ] }
],
"canonicalFeeds": [ { "label": "EPG (XMLTV 1.3)", "apiPath": "/channels/…/epg.xml" }, { "label": "Schedule (JSON v1)", "apiPath": "/channels/…/schedule.json" } ]
}Keys that are checked: roku_dpf, samsung_tvplus, jsonld. Other known keys (lg, vizio, pluto, xumo, amazon, plex)
are listed but not validated. For each selected, checked distributor, rejections lists what its documented requirements would
reject among the titles on this channel's schedule. apiPath values are shown without the /v1 prefix; call them as
/v1/channels/{id}/epg.xml and /v1/channels/{id}/schedule.json. See EPG & exports.
Not yet available
- Per-channel delivery targets. Distributors are a list on the channel, plus the readiness check above.
- Releasing a single lock on its own, and the "shift following titles" and "replace what's there" placement options.
placeonly drops into a gap, andmovewithlocked: falsereleases a lock. - Webhook events for generation.
publish-succeededis sent on publish.
Endpoints
| Operation | Method + path | Scope |
|---|---|---|
| List channels | GET /v1/channels | channels:read |
| Create a channel | POST /v1/channels | channels:write |
| Get a channel | GET /v1/channels/{id} | channels:read |
| Update a channel | PATCH /v1/channels/{id} | channels:write |
| Archive a channel | POST /v1/channels/{id}/archive | channels:write |
| Restore a channel | POST /v1/channels/{id}/restore | channels:write |
| Distribution readiness | GET /v1/channels/{id}/distribution | channels:read |
| List programming blocks | GET /v1/channels/{id}/blocks | channels:read |
| List promotions | GET /v1/channels/{id}/promotions | channels:read |
| Add a promotion | POST /v1/channels/{id}/promotions | channels:write |
| Suggest a schedule | POST /v1/channels/{id}/schedule/generate | schedules:write |
| Simulate a schedule | GET /v1/channels/{id}/schedule/simulate | channels:read |
| Get the schedule | GET /v1/channels/{id}/schedule | channels:read |
| Get the schedule for a range | GET /v1/channels/{id}/schedule/range | channels:read |
| Extend the schedule | POST /v1/channels/{id}/schedule/extend | schedules:write |
| Place a title | POST /v1/channels/{id}/schedule/place | schedules:write |
| Move an item | POST /v1/channels/{id}/schedule/item/move | schedules:write |
| Remove an item | POST /v1/channels/{id}/schedule/item/remove | schedules:write |
| Lock an item | POST /v1/channels/{id}/schedule/lock | schedules:write |
| Fill gaps | POST /v1/channels/{id}/schedule/fill-gaps | schedules:write |
| Clear a range | POST /v1/channels/{id}/schedule/clear | schedules:write |
| Undo the last edit | POST /v1/channels/{id}/schedule/undo | schedules:write |
| Check the publish gate | GET /v1/channels/{id}/publish-gate | channels:read |
| Publish the schedule | POST /v1/channels/{id}/publish | schedules:write |
| List versions | GET /v1/channels/{id}/versions | channels:read |
| Restore a version | POST /v1/channels/{id}/versions/{vid}/restore | schedules:write |