Post lists
A post list is the third way data arrives: somebody writes it, rather than a feed pushing it. The list is a name; the posts on it each choose their own template, hold the content typed into that template's inputs, and say when they go out. At that moment a post becomes an event like any other and takes the same path, on every channel listening to the list.
The API calls these queues, and a post on one is a package. On the dashboard they are on Post
composer, in its Manual post lists card, where the words are post list and post; the permission
area is Post composer (postlists).
| Method | Path | Permission |
|---|---|---|
GET | /v1/queues | postlists:read |
POST | /v1/queues | postlists:write |
PATCH | /v1/queues/{id} | postlists:write |
POST | /v1/queues/{id}/delete | postlists:write |
POST | /v1/queues/{id}/packages | postlists:write |
PATCH | /v1/queues/{id}/packages/{packageId} | postlists:write |
POST | /v1/queues/{id}/packages/{packageId}/delete | postlists:write |
Create a list
A list is a name, and nothing else. Its posts are added one at a time.
curl http://localhost:4000/v1/queues \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{"name":"Docs examples list"}'
{
"queue": {
"id": "01M2SQRG1HVVSB4P1D04WT0T8Z",
"name": "Docs examples list",
"enabled": true,
"posts": [],
"subscribers": 0,
"listeners": [],
"brands": []
}
}
201. name is 1 to 80 characters and must be free within the workspace; a repeat is a 409.
PATCH /v1/queues/{id} takes name and enabled, and answers with the list as queue.
A list switched off (enabled: false) holds nothing back: a post that comes due while it is off
still fires, reaches no channel and is not sent again later. Switch it off to stop a list, not to
pause it; to hold one post, change its time.
A workspace holds up to 50 lists and 5,000 posts across all of them unless Reelwire has set it otherwise; past that, a 409 names the number. Posts that already went out count until they are deleted.
Schedule a post
curl http://localhost:4000/v1/queues/01M2SQRG1HVVSB4P1D04WT0T8Z/packages \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"title": "Weekly market wrap",
"templateId": "tpl_39bc5fecfa42",
"templateVariant": "Corporate glass",
"scheduledFor": "2026-09-25T08:00:00Z",
"data": {
"clipData": {
"title": "Weekly market wrap",
"items": [
{ "text": "Bund yields up", "detail": "The ten-year closed at 2.41 per cent" },
{ "text": "Euro steady" },
{ "text": "Oil lower" }
],
"source": "Orchid Invest"
},
"metaData": {
"title": "Weekly market wrap",
"captionShort": "Three things that moved the week.",
"hashtags": ["markets"]
}
}
}'
{
"package": {
"id": "01M2SQRR3PB0MQ2M2GP9A0ZT82",
"title": "Weekly market wrap",
"templateId": "tpl_39bc5fecfa42",
"templateVariant": "Corporate glass",
"templateVersion": null,
"data": {
"clipData": {
"items": [
{ "text": "Bund yields up", "detail": "The ten-year closed at 2.41 per cent" },
{ "text": "Euro steady" },
{ "text": "Oil lower" }
],
"title": "Weekly market wrap",
"source": "Orchid Invest"
},
"metaData": {
"title": "Weekly market wrap",
"hashtags": ["markets"],
"captionShort": "Three things that moved the week."
}
},
"locales": null,
"scheduledFor": "2026-09-25T08:00:00.000Z",
"state": "SCHEDULED",
"firedAt": null,
"ingestEventId": null,
"error": null,
"waitingForPicturesSince": null,
"waitingFor": [],
"outcome": null
}
}
The body:
| Field | Required | What it is |
|---|---|---|
title | yes | 1 to 120 characters. The post's name on the list, not the post's own title, which is metaData.title. |
templateId | yes | The template this post is drawn with, one this workspace is offered. Any other is a 404. |
templateVariant | no | Which cut, by its name, such as Corporate glass. Absent is the template's first. A cut the template does not have is a 400 that names the ones it has. |
templateVersion | no | An exact major.minor.patch, or "latest". Absent and "latest" both store null, which follows the newest build when the post fires. A version that does not exist is a 400. For now every template is at 1.0.0. |
scheduledFor | yes | An ISO instant, or the string "now". |
data | yes | The content, in the template's own input shape. |
201. There is no field map here. A post is written straight into the template's inputs, so there
is nothing to map from: ask GET /v1/formats/template/{templateId} what
they are, with ?variant= for a template whose variants each take their own content. The example
is List, whose clip is a title and up to twelve items, each a text with an optional detail.
A picture in data is a file from the media library, { "url": "media://...", "alt": "..." }; a web
address is refused. See Sending events for what goes in clipData and
metaData.
Language copies travel inside data, under a locales block that the template checks, not in a
locales field of the request. The response always shows locales: null; that column is no longer
written from a request.
Content is checked when you save, not when it fires
The same validator that guards a stream checks what you send, against the build the post would actually draw with, at the moment you save it. A post that saves is a post that renders.
curl http://localhost:4000/v1/queues/01M2SQRG1HVVSB4P1D04WT0T8Z/packages \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"title": "Missing its points",
"templateId": "tpl_39bc5fecfa42",
"scheduledFor": "now",
"data": { "clipData": { "title": "Missing its points" } }
}'
{
"error": {
"type": "https://reelwire.io/errors/schema_validation",
"code": "schema_validation",
"message": "The post does not fit the template",
"requestId": "01M2SQS36D5F7W5S7WSZPHMADF",
"details": [{ "path": "/clipData", "message": "missing items" }]
}
}
The failure lands with the person who can fix it, rather than in a worker at six on a Sunday. Each
detail names a field and what is wrong with it: missing, not one this template takes, over its
limit, or not one of the values it accepts. It is checked against the post's own variant, since the
variants of a vertical template (Finance, Influencer, Retail, Tech, Public bodies) each take their
own content. The check looks at the shape of a picture, not at the library: a media:// reference to
a file the library does not hold saves, and draws as a missing picture.
POST /v1/templates/{templateId}/validate
names those too, before you save.
A time in the past is refused
A time more than a minute gone is a mistake, not an instruction:
{
"error": {
"type": "https://reelwire.io/errors/schema_validation",
"code": "schema_validation",
"message": "That time has already passed. Pick Right away, or a time to come.",
"requestId": "01M2SQS34CCN1K6S6MZXKD631Y",
"details": []
}
}
Send "scheduledFor": "now" where you mean immediately.
Read a list back
curl http://localhost:4000/v1/queues \
-H "Authorization: Bearer rw_your_key_here"
{
"queues": [
{
"id": "01M2985NYG2GJ450CQAF3DP5YK",
"name": "Enterprise 2 Post list 1",
"enabled": true,
"posts": [],
"subscribers": 3,
"listeners": [
{ "channelId": "01M1VFRWE7EWSY5D7QDQ0KNK1S", "channelName": "Channel 1 en", "locale": "en", "brandId": "01M1VFRWDZ6KBJ1T4Q3V5N8X2C", "brandName": "Brand 1" },
{ "channelId": "01M2985NXBN97TVSF6AN19TDN2", "channelName": "Channel 7 en", "locale": "en", "brandId": "01M1VFRWDZ6KBJ1T4Q3V5N8X2C", "brandName": "Brand 1" },
{ "channelId": "01M2985NXSH4KRQHCP6WJ2KMFR", "channelName": "Channel 13 en", "locale": "en", "brandId": "01M1VFRWDZ6KBJ1T4Q3V5N8X2C", "brandName": "Brand 1" }
],
"brands": [
{
"id": "01M1VFRWDZ6KBJ1T4Q3V5N8X2C",
"name": "Brand 1",
"text": {
"description": "Trading involves risk.",
"link": { "label": "Read more", "url": "https://example.com/more" },
"hashtags": ["markets"]
},
"locales": { "de": { "description": "Handel birgt Risiken.", "hashtags": ["maerkte"] } }
}
]
}
]
}
Lists come in name order, and the posts on each (posts, in the shape above) by the time they go
out. listeners is every channel listening to the list, with its language (locale) and its
brand: those languages are the ones worth writing a post's locales for, since each channel draws
its own language's copy laid over the default. brands is each brand of the listening channels
once, with what it adds after every post's own text (text: its description, link and
hashtags), and the same in each language the brand wrote its own post text for (locales, by
language code).
A post's state is SCHEDULED until its time, then FIRED; CANCELLED once cancelled, and
FAILED, with the reason in error, when it could not fire at all, such as a template left with no
build to render with.
A post whose content names a picture (media://) the media library does not hold is never fired without it: when
its time comes it stays SCHEDULED, waitingForPicturesSince says since when, waitingFor lists the file names,
and it fires as soon as every one is in the library, checked about once a minute. Both are null and [] for a
post not waiting. A post may be written naming a picture still to come, so a plan can be made before its photos
are taken; whoever publishes is emailed a few days before it is due while a picture is missing. Changing its
data or scheduledFor starts it over.
Each post carries an outcome once it has fired, counted over the channels it reached:
channels, rendering, rendered, renderFailed, published, handedOver (handed over by email
or webhook, and nobody has reported it published yet), emailed (the same number, under the name it
had while email was the only way), awaitingApproval and publishFailed.
outcome is null for a post still to go. A post fires whether or not anybody listens, so "sent"
alone cannot say whether a video was made: this can.
Changing and cancelling
PATCH /v1/queues/{id}/packages/{packageId} accepts title, scheduledFor, templateId,
templateVariant, templateVersion, data, and state where state is "CANCELLED" (any other
state is a 400). It answers with the post as package. A new template, cut, build or content is
checked again as a whole, because content that fitted the old template may not fit the new one.
Two things to know. Only a post still in SCHEDULED can be changed; a post that went out is a
record of something that happened, and editing it would rewrite history. And changing the time
re-validates the content, because a template that prints when the fact happened reads it from the
content, where the time a post fires is written in for it (occurredAt, only for a template that
takes one).
POST /v1/queues/{id}/packages/{packageId}/delete removes a post from the list and answers
{ "ok": true }.
POST /v1/queues/{id}/delete removes the list and every post on it, and unsubscribes the channels
that were listening, together with the posting jobs they made from it and their clips. The response
says how many channels:
{ "ok": true, "removedFromChannels": 3 }
Getting a list published
Making a post list does not publish anything on its own. A channel has to subscribe to it, with
kind: "QUEUE"; see Channels.
A post fires whether or not anybody listens, and one nobody listened to made nothing: its outcome
says channels: 0. The channel pins nothing for a list: each post chooses its own template, cut
and build. Whether a post then waits for somebody to approve it is the channel's own approval
setting, as it is for every other source.
What each post says beside its clip is what was written in its metaData: the title, and the
description in the length that fits each listening channel's platform (the four lengths and which
platforms take which are on Templates and formats). Write the lengths
your channels need; one left out is filled from the next shorter one written, or failing that the
next longer one. Nothing is written for a manual post behind the writer's back: a description or
hashtags left empty go out empty. Only an empty title is filled in, from the clip's own title (its
clipData title and label, or headline), or the list's name where there is none, because some
platforms will not take a video without one.
Making a post again
GET /v1/jobs/{id}/reuse answers with what a finished post was made
from. Send its templateId, variant as templateVariant, pinnedVersion as templateVersion
and data to POST /v1/queues/{id}/packages, with a title and a scheduledFor, and the post goes
out again with whatever you changed; for a post that came from a list, queueId names the list. On
the dashboard, Reuse on the Posts screen, As a new post, opens Add new post on Post composer with all
of it filled in.
Plan the next weeks
GET /v1/queues/{id}/plan answers when the next weeks of posts on this list can go out, and how many
fit: what an assistant's content plan is built on (reelwire_plan_posts). It reads and changes nothing,
and it never says what a post should say.
| Query | What it is |
|---|---|
weeks | How many weeks ahead, 1 to 13. Default 12. |
from | When the plan starts, ISO 8601. Default now. |
timeZone | Where the audience lives, an IANA zone such as Europe/Berlin. Default a guess from the first listening channel's language, answered with timeZoneAssumed: true. |
postsPerWeek | 1 to 21. Default what the allowance pays for, at most three. |
days | Weekdays, Mon,Wed,Fri or 1,3,5, instead of spreading postsPerWeek evenly. |
times | Local times, HH:MM between 06:00 and 20:00, at most three. Default 09:00. |
Every slot keeps three rules. The allowance, period by period: a post renders once for every channel
listening to the list, and what a period has left is its videos less those used, less what the channels
already have coming (the Posting calendar's entries, feeds and lists alike), less what the plan adds. The
ramp, per channel and local day: at most one post a day in a channel's first week, two in weeks two and
three, three from week four, counted from its first post. Daylight: every time is between 06:00 and
20:00 where the audience lives. A slot that breaks one is in dropped, with why (allowance or ramp).
{
"queue": { "id": "01M2SQRG1HVVSB4P1D04WT0T8Z", "name": "Default post list", "enabled": true },
"window": { "from": "2026-10-26T00:00:00.000Z", "to": "2027-01-18T00:00:00.000Z", "weeks": 12 },
"timeZone": "Europe/Berlin",
"timeZoneAssumed": false,
"rendersPerPost": 1,
"postsPerWeek": 3,
"slots": [
{ "at": "2026-10-26T08:00:00.000Z", "local": "Mon 2026-10-26 09:00", "week": 1 },
{ "at": "2026-10-28T08:00:00.000Z", "local": "Wed 2026-10-28 09:00", "week": 1 }
],
"dropped": [],
"droppedCount": { "allowance": 0, "ramp": 0 },
"periods": [
{ "start": "2026-10-12T09:30:00.000Z", "end": "2026-11-12T09:30:00.000Z", "allowance": 15, "used": 2, "booked": 0, "planned": 5, "left": 8, "morePosts": 8 }
],
"channels": [
{ "id": "01M1W0W9Q1ZB5Y8WQF8R8C3V7K", "name": "Default channel", "locale": "de", "firstPostAt": null, "weekAtStart": 1, "postsPerDayAtStart": 1 }
]
}
Write a whole plan at once
POST /v1/queues/{id}/packages/batch takes posts, a list of up to 100, each with the fields of a single
post (title, templateId, templateVariant, templateVersion, scheduledFor, data). Every post is
checked exactly as one is, and nothing is written unless all of them fit: a refusal is a 400 whose
details name each post that does not by its number, /posts/3/clipData/title. The answer is packages, the
posts written, in time order, with a 201.
Mistakes people make
Waiting for a validation failure at fire time. There is not one. The content is checked when you save it, against the build it would draw with.
Scheduling into the past to publish now. It is refused. Use "now".
Sending locales beside data. Language copies go inside data, where the template validates
them.
Editing a post that already fired. A 409. Create a new post.
Pinning templateVersion and forgetting it. A pin to an exact build stops following new ones.
Send "latest", or leave it out, unless you have a reason.
Deleting a list to tidy up. Every channel listening to it loses that subscription, and the
posting jobs it made go with it. Check listeners first, or set enabled: false, which keeps the
subscriptions.
Switching a list off to pause it. A post that comes due while the list is off fires to nobody and is not sent later. Move the post's time, or cancel it, instead.