Posts and jobs
A post is one piece of content in one workspace: everything one feed item, one custom feed event
or one post-list post produces, for every channel that listens to it. Each channel of a post still
gets its own render, in its own shape and language, its own approval where the channel asks for one,
and its own state. The API calls one channel of a post a job, which is why the paths say
/v1/jobs: a job is the thing you watch and act on (cancel it, change its text, ask for it to be
taken down), and the channel's post inside it is the record of what happened on that channel.
Reelwire makes the video. When a post is due it is handed over the ways its channel has switched on, in any mix: by email or by webhook, and whoever receives it posts it and then says so; or through the channel's platform connector, which posts it on the platform itself and says so (Channels). A channel with every way switched off hands its posts only to the syndication participants whose group takes it. See Webhooks for the webhook, and Posts handed over by email below for the email and its post kit, whose addresses need no key and are not in the table.
| Method | Path | Permission |
|---|---|---|
GET | /v1/jobs | posts:read |
POST | /v1/jobs/{id}/cancel | posts:write |
POST | /v1/jobs/{id}/repost | posts:write |
POST | /v1/jobs/{id}/update | posts:write |
POST | /v1/jobs/{id}/comments | posts:write |
PATCH | /v1/jobs/{id}/comments/{commentId} | posts:write |
POST | /v1/jobs/{id}/comments/{commentId}/delete | posts:write |
POST | /v1/jobs/{id}/resend-email | posts:write |
POST | /v1/jobs/{id}/resend-webhook | posts:write |
POST | /v1/jobs/{id}/resend-connector | posts:write |
POST | /v1/jobs/{id}/resend-participants | posts:write |
POST | /v1/jobs/{id}/retry | posts:write |
POST | /v1/jobs/{id}/retry-render | posts:write |
POST | /v1/jobs/{id}/mark-published | posts:write |
POST | /v1/jobs/{id}/skip | posts:write |
POST | /v1/jobs/{id}/mark-deleted | posts:write |
POST | /v1/jobs/{id}/kit-link | posts:write |
POST | /v1/jobs/{id}/archive | posts:write |
POST | /v1/jobs/{id}/unarchive | posts:write |
POST | /v1/jobs/{id}/delete | posts:write |
GET | /v1/jobs/{id}/download | posts:read |
GET | /v1/jobs/{id}/reuse | posts:read |
GET | /v1/posts | posts:read |
POST | /v1/posts/{id}/report | posts:write |
GET | /v1/posts/{id}/package | posts:read |
GET | /v1/pipeline | posts:read |
GET | /v1/pipeline/{id} | posts:read |
GET | /v1/overview | posts:read |
GET | /v1/calendar | schedule:read |
GET | /v1/calendar/still | schedule:read |
GET | /v1/calendar/export | schedule:read |
GET | /v1/calendar/export/link | schedule:read |
GET | /v1/approvals | approvals:read |
POST | /v1/approvals/{id} | approvals:write |
List posts
GET /v1/jobs lists the posts: one row per post, its channels inside it. It pages, searches and
filters like every log; see Lists and paging. With by=channel it gives
one row per channel instead, which is what the Posts screen shows: see Filters.
curl "http://localhost:4000/v1/jobs?perPage=1" \
-H "Authorization: Bearer rw_your_key_here"
This is the post from the worked example on Sending events, for two channels: Channel 4 de was handed over by webhook and reported published, and Channel 10 en waits for a reviewer.
{
"jobs": [
{
"id": "01M2SQN507TDA0KNTJJABHBR9A",
"postKey": "01M2SQN507TDA0KNTJJABHBR9A",
"status": "AWAITING_APPROVAL",
"summary": "1 of 2 published",
"progress": 0,
"priority": false,
"createdAt": "2026-09-18T07:45:50.099Z",
"updatedAt": "2026-09-18T07:47:02.316Z",
"origin": "custom",
"details": "Enterprise 2 Stream 1",
"postTitle": null,
"brandName": "Brand 1",
"channelCount": 2,
"channelNames": ["Channel 4 de", "Channel 10 en"],
"caption": "The ten-year settled at 2.41 per cent.",
"title": "Bund yields close at a three-week high",
"email": "off",
"webhook": "partly",
"connector": "off",
"syndication": "off",
"approvalId": "01M2SQPASCPHZ65QRPX9X628GD",
"approval": { "required": true, "state": "PENDING", "login": null, "name": null, "waiting": 1 },
"downloadable": true,
"archived": false,
"channels": [
{
"id": "01M2SQN50JQFC00PZE93M9E33R",
"postId": "01M2SQPANAT1W066PMYNXFTCNZ",
"status": "PUBLISHED",
"state": "PUBLISHED",
"progress": 1,
"createdAt": "2026-09-18T07:45:50.099Z",
"updatedAt": "2026-09-18T07:47:02.316Z",
"origin": "custom",
"details": "Enterprise 2 Stream 1",
"postTitle": null,
"channelId": "01M2985NX4V0WA1GV9MC63DEPJ",
"channelName": "Channel 4 de",
"brandName": "Brand 1",
"platform": "instagram",
"surfaceLabel": "Instagram Reels",
"ratio": "9x16",
"locale": "de",
"caption": "The ten-year settled at 2.41 per cent.",
"title": "Bund yields close at a three-week high",
"publishedAt": "2026-09-18T07:47:02.310Z",
"permalink": "https://www.instagram.com/reel/C9x2Lm4oQpR/",
"scheduledFor": "2026-09-18T07:46:28.711Z",
"error": null,
"templateName": "Big number",
"templateVariant": "Corporate glass",
"templateVersion": "1.0.0",
"renderJobId": "01M2SQN51TGR960ZKWNVKH1HQJ",
"deliverByEmail": false,
"deliverByWebhook": true,
"deliverByConnector": false,
"syndicated": false,
"email": "off",
"webhook": "sent",
"connector": "off",
"handedOver": true,
"deleteRequested": false,
"updatable": true,
"resendable": true,
"retryable": false,
"renderRetryable": false,
"renderRetryCounts": false,
"markable": false,
"skippable": false,
"deletable": true,
"markDeletable": false,
"repostable": true,
"downloadable": true,
"priority": true,
"archived": false,
"approvalId": null,
"approval": { "required": false, "state": null, "login": null, "name": null },
"clip": {
"id": "01M2SQPAJYJR7YB27466FV30SZ",
"mimeType": "video/mp4",
"url": "http://localhost:4000/v1/assets/01M2SQPAJYJR7YB27466FV30SZ/download?exp=...&sig=..."
}
},
{
"id": "01M2SQN50TAP4BXAYDZRC9WMBC",
"postId": "01M2SQPARBQPG1BRENTHBM1W08",
"status": "AWAITING_APPROVAL",
"state": "AWAITING_APPROVAL",
"progress": 1,
"createdAt": "2026-09-18T07:45:50.106Z",
"updatedAt": "2026-09-18T07:46:28.781Z",
"origin": "custom",
"details": "Enterprise 2 Stream 1",
"postTitle": null,
"channelId": "01M2985NXJRXVE5HKCCGKASP8P",
"channelName": "Channel 10 en",
"brandName": "Brand 1",
"platform": "instagram",
"surfaceLabel": "Instagram Reels",
"ratio": "9x16",
"locale": "en",
"caption": "The ten-year settled at 2.41 per cent.",
"title": "Bund yields close at a three-week high",
"publishedAt": null,
"permalink": null,
"scheduledFor": null,
"error": null,
"templateName": "Big number",
"templateVariant": "Corporate glass",
"templateVersion": "1.0.0",
"renderJobId": "01M2SQN51XWQ3D8E0T4B6RZK2M",
"deliverByEmail": false,
"deliverByWebhook": true,
"deliverByConnector": false,
"syndicated": false,
"email": "off",
"webhook": "waiting",
"connector": "off",
"handedOver": false,
"deleteRequested": false,
"updatable": false,
"resendable": false,
"retryable": false,
"renderRetryable": false,
"renderRetryCounts": false,
"markable": false,
"skippable": false,
"deletable": false,
"markDeletable": false,
"repostable": false,
"downloadable": true,
"priority": true,
"archived": false,
"approvalId": "01M2SQPASCPHZ65QRPX9X628GD",
"approval": { "required": true, "state": "PENDING", "login": null, "name": null },
"clip": {
"id": "01M2SQPAPH4YF66ZKK5Y5XA9Y7",
"mimeType": "video/mp4",
"url": "http://localhost:4000/v1/assets/01M2SQPAPH4YF66ZKK5Y5XA9Y7/download?exp=...&sig=..."
}
}
]
}
],
"total": 5,
"all": 5,
"page": 1,
"perPage": 1,
"stages": {
"rendering": 0,
"approval": 1,
"scheduled": 0,
"publishing": 0,
"handedOver": 0,
"published": 4,
"skipped": 0,
"failed": 0,
"rejected": 0,
"cancelled": 0,
"deleted": 0
},
"unstaged": 5,
"facets": {
"stage": ["rendering", "approval", "scheduled", "publishing", "handedOver", "published", "skipped", "failed", "rejected", "cancelled", "deleted"],
"archive": ["archived"],
"approval": ["upcoming", "pending", "approved", "rejected", "none"],
"channelId": ["01M2985NXJRXVE5HKCCGKASP8P", "01M2985NX4V0WA1GV9MC63DEPJ"],
"brandId": ["01M1VFRWE7VHP3H54ESRJQ0SAJ"],
"platform": ["instagram"],
"origin": ["custom"]
},
"labels": {
"channelId": {
"01M2985NXJRXVE5HKCCGKASP8P": "Channel 10 en",
"01M2985NX4V0WA1GV9MC63DEPJ": "Channel 4 de"
},
"brandId": { "01M1VFRWE7VHP3H54ESRJQ0SAJ": "Brand 1" }
}
}
Two ids, for two things. A row's id is the post, its postKey: the id every hand-over of it
carries. A channel's id in channels is what every /v1/jobs/{id}/... action below takes, and
what /v1/pipeline/{id} reads. A channel's postId is its channel's post, the id a webhook receiver
reports back against.
What a row says about the whole post:
statusis the one of its channels that most needs somebody. See A post's status.summarysays how far a post of several channels is, in words: "2 of 3 published", "1 of 3 handed over", or "3 channels". It isnullfor a post of one channel.email,webhookandconnectorsay how each way out went, over its channels:off,waiting(not handed over yet),sending,retrying,sent,failed, orpartlywhere its channels went at different times, as a channel with a reviewer follows the others on its own. A channel carries the same values for itself, withoutpartly.syndicationsays the same of the post's syndication participants,offwhere none take it, and a channel carries it too,partlythere where some participants were reached and some not.approvalIdis the waiting review where exactly one channel waits, andapproval.waitinghow many channels wait.captionandtitleare its first channel's,downloadablesays whether any channel has a video to give, andarchivedis true where every one of its channels was archived.
What a channel says about itself:
statusis worked out from its render and its post. See Statuses and stages.stateis its post's stored state, asGET /v1/postshas it, ornullbefore there is a post.captionandtitleare its post's own text. Where the content brought its text undermetaData, the caption comes in four lengths,captionTiny(200 characters),captionShort(500),captionMedium(1,500) andcaptionLong(4,000), and each channel's post takes the longest its platform has room for beside the brand's own text; see Templates. A channel on No specific platform keeps all four: its post kit and its download show them one per kind of platform, and an update changes them withcaptions.deliverByEmail,deliverByWebhookanddeliverByConnectorare the channel's switches as they are now, andemail,webhookandconnectorwhat each way did for it:off,waiting,sending,retrying,sentorfailed. For the connector,sentis posted on the platform.connectorCanis what the channel's platform connector can do with this post:updateanddeletewhether its platform lets it change or take down a post,updateTitlewhether that change takes the title too, which only YouTube's does,postedwhether the connector posted this one (neither works otherwise),refusesSameTextwhether the platform refuses text it already has, soresend-connectoronly goes through there once the text has changed, andplatformthe platform's name.syndicatedis true while a syndication group with somebody signed up takes the channel: its posts then also go on to those participants, whatever the switches say.handedOveris true once it was handed over, whatever was said about it since, anddeleteRequestedwhile a delete was handed over and nobody has said the post is gone.publishedAtis when it was handed over, until somebody reports it published (or the platform connector posted it); then it is when that happened.permalinkis the link to the post, where somebody reported one or the connector posted it.approvalsays whether it needs a reviewer and where that stands:stateisPENDING,APPROVED,REJECTED,CANCELLED,UPCOMINGwhile a channel that asks for approval is still rendering the clip, ornullwhere nobody is asked;loginandnamesay who decided.kindispost, orupdatefor a change to a post that went out, which carries thechangeasked for;neededis how many approvals it takes (2 where an Enterprise channel asks for two approvers),firstwho gave the first of two while the second is awaited, andrequestedBywho wrote the post or asked for the change, each asloginandname.archivedis true once somebody archived it. See Archive and unarchive.erroris why it stopped, where it did: the render's reason, or the post's.
The flags tell you which action a channel will accept right now. Read them rather than guessing from the status, and you will not have to handle a 409:
| Flag | Action | True while |
|---|---|---|
updatable | update | It was handed over (handed over, published or skipped), and no change or delete is on its way. |
resendable | resend-email, resend-webhook, resend-connector, resend-participants | It is handed over, published or skipped, and nothing else is on its way; each also needs its way switched on on the channel, and resend-participants a syndication group with somebody signed up that takes it (syndicated). |
retryable | retry | It failed after its clip was made (POST_FAILED), or it was deleted (DELETED) and nothing is still on its way; either way, the clip is still stored. |
renderRetryable | retry-render | Its clip could not be rendered (RENDER_FAILED), and its channel was not removed. renderRetryCounts says whether the retry takes a video. |
markable | mark-published | It is handed over or skipped, and nobody asked for it to be taken down. A failed post is tried again with retry instead. |
skippable | skip | It is handed over, and nobody has said anything about it yet. |
deletable | delete | It is handed over or published, and no change or delete is on its way. |
markDeletable | mark-deleted | A delete was handed over, and nobody has said the post is gone; or no way got a delete through (DELETE_FAILED), for a post somebody took down by hand; or the connector is the channel's only way and cannot take this post down (connectorCan). |
repostable | repost | It was handed over once, and its channel is live with Webhook switched on. |
downloadable | download | Its video is still stored. |
stage, archive and approval in facets are the fixed choices. The rest of facets, and
labels, are read from the rows the workspace actually holds, not from a fixed list, so a channel,
brand, platform or origin that appears there is one that can return something.
Filters
| Parameter | Values |
|---|---|
stage | rendering, approval, scheduled, publishing, handedOver, published, skipped, failed, rejected, cancelled, deleted |
channelId | A channel id |
brandId | A brand id |
platform | The channel's format: youtube, facebook, instagram, tiktok, linkedin, telegram, discord, slack, x, pinterest, threads, or download, which is No specific platform |
origin | shipped, custom, manual |
approval | upcoming (a channel that asks for approval, its clip still rendering), pending, approved, rejected, none |
by | channel: one row per channel of a post rather than one per post |
archive | archived: only what somebody archived. Without it, only what nobody did |
A post is listed when one of its channels matches, and then with every one of its channels.
With by=channel, each channel is a row of its own: channels holds that one channel, the row's
id is the channel's id (the one every action below takes), and postKey still names its post.
The filters, the search and the stage counts then count channels. How a post is handed over does not
change: the channels of one post that go together still go in one email and one webhook call.
Statuses and stages
A channel's status is worked out, not stored: its render's state until the clip exists, then its
post's, with an update or a delete on its way laid over it. A stage is a set of statuses, not a
status:
| Stage | Channel statuses |
|---|---|
rendering | ACCEPTED, AWAITING_RENDERING, RENDERING, DOWNLOADING |
approval | AWAITING_APPROVAL |
scheduled | SCHEDULED |
publishing | HANDING_OVER, UPDATING |
handedOver | HANDED_OVER |
published | PUBLISHED |
skipped | SKIPPED |
failed | RENDER_FAILED, POST_FAILED, UPDATE_FAILED, DELETE_FAILED |
rejected | REJECTED |
cancelled | CANCELLED, REPLACED |
deleted | DELETE_REQUESTED, DELETED |

publishing is being handed over right now, and handedOver is handed over and not reported on
yet. The old stage emailed, from when only an email could hand a post over, still works and means
handedOver.
HANDED_OVER is not published: it means a way out delivered the post, and nobody has said yet
whether it went up. PUBLISHED comes only when somebody says so, or when the channel's platform
connector posted it. SKIPPED is handed over and deliberately not posted. POST_FAILED is a post
that could not be handed over after every attempt, or that whoever posts it reported could not be
posted. DELETE_REQUESTED is a delete handed over and waiting for somebody to say the post is gone,
and DELETED is once they have, or once the platform connector took it down.
A post is in a stage when one of its channels is, and each stage counts posts, so one post can count in two stages.
The period a post falls in is when something happened to it, not only when its content arrived:
since and until match a post that arrived, rendered, or any of whose channels changed inside the
window. A post that arrived yesterday and was published ten minutes ago is in the last hour.
A post's status
A post's status is the one of its channels that most needs somebody: a failure first; then the
least advanced of the channels still under way, in the order ACCEPTED, AWAITING_RENDERING,
RENDERING, DOWNLOADING, AWAITING_APPROVAL, SCHEDULED, HANDING_OVER, UPDATING,
DELETE_REQUESTED, HANDED_OVER, which leaves one waiting for somebody's word to the last; then a
finished one, published before skipped, deleted, rejected and cancelled.
One row per channel
GET /v1/posts is the same log at the level of one channel's post: one publication on one channel,
in one language and one shape. It filters on state and platform.
curl "http://localhost:4000/v1/posts?perPage=1" \
-H "Authorization: Bearer rw_your_key_here"
{
"posts": [
{
"id": "01M2SQPANAT1W066PMYNXFTCNZ",
"postGroupId": "01M2SQN50JQFC00PZE93M9E33R",
"origin": "custom",
"details": "Enterprise 2 Stream 1",
"postTitle": null,
"state": "PUBLISHED",
"platform": "instagram",
"surfaceLabel": "Instagram Reels",
"channelName": "Channel 4 de",
"brandName": "Brand 1",
"locale": "de",
"ratio": "9x16",
"title": "Bund yields close at a three-week high",
"caption": "The ten-year settled at 2.41 per cent.",
"hashtags": ["bunds", "rates"],
"createdAt": "2026-09-18T07:46:28.705Z",
"scheduledFor": "2026-09-18T07:46:28.711Z",
"publishedAt": "2026-09-18T07:47:02.310Z",
"platformPostId": "17912345678901234",
"permalink": "https://www.instagram.com/reel/C9x2Lm4oQpR/",
"failureReason": null,
"connected": true,
"assets": [
{
"assetId": "01M2SQPAJYJR7YB27466FV30SZ",
"url": "http://localhost:4000/v1/assets/01M2SQPAJYJR7YB27466FV30SZ/download?exp=...&sig=...",
"expiresAt": "2026-09-18T08:50:22.000Z"
}
],
"clipStored": true
}
],
"total": 7,
"all": 7,
"page": 1,
"perPage": 1,
"facets": { "state": ["AWAITING_APPROVAL", "PUBLISHED"], "platform": ["instagram"] }
}
id is the channel's post, the postId a webhook receiver reports back against, and postGroupId
is its channel's id on GET /v1/jobs, which the actions take. platformPostId and permalink
are what whoever posted it reported back, or what the platform connector answered when it posted
it, and they travel with a later update or delete so the post can be found again. Asset URLs are
signed and expire; expiresAt says when, so mint a fresh one rather than storing the URL.
A channel's post's state is one of DRAFT, PENDING_RENDER, RENDERED, AWAITING_APPROVAL,
APPROVED, REJECTED, SCHEDULED, PUBLISHING (being handed over), HANDED_OVER (a way out
delivered it, and nobody has said yet whether it went up), PUBLISHED (somebody said it is up, or
the platform connector posted it), SKIPPED (handed over and deliberately not posted), FAILED (it
could not be handed over, or whoever posts it said it could not be posted, and failureReason says
why), RECALLED (taken down, and somebody or the platform connector said so), REPLACED and
CANCELLED (it will never go out: somebody cancelled it, or its clip was removed to make room).
clipStored: false means the video has been removed to make room in the workspace's storage
allowance, or deleted under Rendered clips. The post is still a record of what went out; there is no
file left to download. It is null for a post that never had a clip.
connected says whether a way out of the workspace's own can carry the post: its channel has Email
switched on, or the workspace's webhook address is set and switched on.
Follow a post while it runs
GET /v1/pipeline is the live view: what is rendering, what is being handed over, and what finished
recently. It tells you how often to ask. since and until narrow it to posts that arrived in a
window; without them the counts cover everything the workspace has.
curl http://localhost:4000/v1/pipeline \
-H "Authorization: Bearer rw_your_key_here"
{
"at": "2026-09-18T07:50:22.201Z",
"pollMs": 2000,
"render": { "counts": { "COMPLETED": 7 }, "inFlight": [], "recent": [] },
"posts": {
"counts": { "PUBLISHED": 7 },
"clipCounts": { "PUBLISHED": 7 },
"inFlight": [],
"recent": [
{
"id": "01M2SQN50TAP4BXAYDZRC9WMBC",
"kind": "postGroup",
"state": "PUBLISHED",
"templateId": "tpl_dd7e6b264fb8",
"brand": "Brand 1",
"channelName": "Channel 10 en",
"streamId": "st_30472168d941",
"source": "st_30472168d941",
"title": "Bund yields close at a three-week high",
"createdAt": "2026-09-18T07:46:28.748Z",
"stageSeconds": 4,
"stageSince": "2026-09-18T07:50:18.611Z",
"error": null,
"clips": [
{
"id": "01M2SQPARBQPG1BRENTHBM1W08",
"platform": "instagram",
"locale": "en",
"ratio": "9x16",
"state": "PUBLISHED",
"scheduledFor": "2026-09-18T07:50:17.386Z",
"publishedAt": "2026-09-18T07:50:18.608Z",
"permalink": "https://www.instagram.com/reel/C9x3Nq7pRtS/",
"error": null
}
]
}
]
},
"limit": 12
}
pollMs is the interval the server suggests. Honour it rather than picking your own. Each lane
shows at most limit channels, and counts covers them all, so a lane can say "6, showing 4".
GET /v1/pipeline/{id} is everything known about one channel of a post, by the channel's id: where
its data came from, the brand, channel, source and template that drew it, the render, the approval
and its post with every hand-over that carried it, by email, by webhook or by platform connector.
It also carries what the post kit gives: the texts to copy and, while the video is still stored, the
video and cover links. Its prompt fields are always null, and a later major version drops them.
GET /v1/overview is the count of everything, useful as a health panel:
{
"brands": 3,
"channels": 15,
"subscriptions": 57,
"templates": { "mirrored": 14 },
"ingest": { "total": 1, "last24h": 1 },
"renderJobs": { "COMPLETED": 7 },
"assets": 7,
"posts": { "AWAITING_APPROVAL": 1, "PUBLISHED": 6 },
"attention": { "pendingApprovals": 1, "deadDeliveries": 0 }
}
Watch attention rather than computing it yourself: a dead delivery is a hand-over that never got
through, and a pending approval is a post waiting on a person.
Approvals
A channel can be set to hold every post for a reviewer. Those posts sit in AWAITING_APPROVAL,
rendered and ready, until somebody decides. So does an update of a post that went out, on such a
channel (see Update a post that was handed over). Approval
comes with Small business and Enterprise: on the plans below, a channel cannot be set to it (a 409)
and a key is not offered the Posts - Approvals permission.
curl "http://localhost:4000/v1/approvals?perPage=3" \
-H "Authorization: Bearer rw_your_key_here"
{
"approvals": [
{
"id": "01M2SQPASCPHZ65QRPX9X628GD",
"requestedAt": "2026-09-18T07:46:28.781Z",
"channelName": "Channel 10 en",
"templateId": "tpl_dd7e6b264fb8",
"templateVersion": null,
"posts": [
{
"id": "01M2SQPARBQPG1BRENTHBM1W08",
"platform": "instagram",
"locale": "en",
"ratio": "9x16",
"caption": "The ten-year settled at 2.41 per cent.",
"state": "AWAITING_APPROVAL",
"assets": [
{
"assetId": "01M2SQPAPH4YF66ZKK5Y5XA9Y7",
"url": "http://localhost:4000/v1/assets/01M2SQPAPH4YF66ZKK5Y5XA9Y7/download?exp=...&sig=...",
"expiresAt": "2026-09-18T08:50:12.000Z"
}
]
}
]
}
],
"total": 1,
"all": 1,
"page": 1,
"perPage": 3,
"facets": {}
}
The list holds what is waiting now, oldest first, because a queue is worked from the front.
templateVersion is null where the channel follows the newest build. Each request also says:
kind:POST, a post waiting to be handed over at all, orUPDATE, a change to a post that went out. AnUPDATEcarrieschange, the update as it was asked for (the same fields as the update's body), so a reviewer approves the words themselves.requestedBy: who wrote the post or asked for the change, a sign-in address or an API key's id.needed: how many approvals it takes,1, or2where the channel asks for two approvers (Enterprise).firstBynames who gave the first of two while it waits for the second, and isnullotherwise.authorMayApprove: whether whoever is named inrequestedBymay be one of those approvers, as the channel said when this started waiting (Enterprise).
Decide it with POST /v1/approvals/{id}. The body takes state, which is APPROVED or
REJECTED and defaults to APPROVED, and an optional note. CHANGES_REQUESTED sends it back instead
of deciding it: see Asking for changes.
curl http://localhost:4000/v1/approvals/01M2SQPASCPHZ65QRPX9X628GD \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{"state":"APPROVED","note":"Figures checked against the agency release."}'
{ "postIds": ["01M2SQPARBQPG1BRENTHBM1W08"], "state": "APPROVED" }
An approval that is not this workspace's is a 404, and one that was already decided a 409.
Approving an UPDATE hands the change over, the ways it was asked for, as the person who asked would
have; rejecting it leaves the post as it went out. Where the post changed since so that the change can
no longer be made, approving it is a 409 saying why, and it stays waiting.
On Enterprise, two rules hold (set out on the Compliance
page). Nobody approves a post or an update they wrote themselves: approving one whose requestedBy is
you is a 403, unless its authorMayApprove is true. A channel asking for two approvers takes two
different people: the first approval answers
{ "postIds": [], "state": "PENDING", "approvals": 1, "needed": 2 }
and the request waits, with firstBy set, for somebody else; the same person approving again is a
403. Rejecting is not held to either rule: anybody who may approve stops a post, its author included,
at either step. Every decision is an entry of the compliance record.
An approved channel is handed over on its own, as soon as it is due: it does not wait for its post's
other channels, and they never waited for it. A rejection is final: a rejected channel's post is
never handed over and never retried. Approving needs approvals:write; it is a separate permission
from posts:write on purpose, because the person signing a post off is often not the person allowed
to edit it. An AI assistant's connection holds it only where the person connecting switched that on
as well; see An assistant's connection.
Asking for changes
POST /v1/approvals/{id} with state CHANGES_REQUESTED and a note, which it needs, sends the post or
the update back with that note as a comment, and answers:
{ "postIds": [], "state": "CHANGES_REQUESTED" }
Nothing is handed over and nothing moves on: a post stays unapproved, and an update leaves the post as it
went out. The request can no longer be decided, which is a 409. On GET /v1/jobs the row's
approval.state reads CHANGES_REQUESTED, its approvalId is null, and approval=changes finds them.
POST /v1/jobs/{id}/update reworks it, with the body of an
update and an optional comment for whoever approves it. On a post
that was not handed over yet, which the row's reworkable says, nothing is handed over and ways is not
read: what you send becomes the post at once, and it goes back for approval as a new request.
{ "ok": true, "queued": 0, "awaitingApproval": true, "approvalId": "01M2SQPASCPHZ65QRPX9X628GE", "reworked": true }
This also works while the request still waits, and the old approvalId is then a 409. A first of two
approvals does not carry over. On Enterprise, whoever reworked a post is refused as its approver, as its
author is. On an update sent back, the same call asks for the new update in its place.
POST /v1/jobs/{id}/comments with { "text": "..." }, up to 2,000 characters, says something about any post
and moves nothing. A person needs to work on posts or approve them. GET /v1/jobs answers how many a channel
has as comments, and GET /v1/pipeline/{id} answers them as comments, oldest first, each with kind
(COMMENT, CHANGES_REQUESTED or RESUBMITTED), body, createdAt, editedAt and by. Whoever wrote a COMMENT may
change it with PATCH /v1/jobs/{id}/comments/{commentId} and { "text": "..." }, or remove it with
POST /v1/jobs/{id}/comments/{commentId}/delete; anybody else is a 403, and the other two kinds are a 409, since they say what happened
to the post. A rejected post can still be commented on, but not reworked.
Acting on a channel
Every action takes a channel's id, channels[].id on GET /v1/jobs, and acts on that channel's
post alone: the post's other channels are untouched. Each answers with what it did, and a 409 with a
sentence where the channel is not in a state that accepts it. An id that is not one of your channels'
posts gets the same 409 with a sentence saying so, except from archive, unarchive and reuse,
which answer 404.
Cancel
POST /v1/jobs/{id}/cancel stops everything still to happen to one channel's post: a render that has
not finished, a review not yet decided, a hand-over not yet sent, including one waiting to be tried
again. The post's other channels go on without it. It cannot reach into the past: once the post was
handed over there is nothing left to cancel.
curl -X POST http://localhost:4000/v1/jobs/01M2SQN50JQFC00PZE93M9E33R/cancel \
-H "Authorization: Bearer rw_your_key_here"
{
"error": {
"type": "https://reelwire.io/errors/conflict",
"code": "conflict",
"message": "There is nothing left to cancel: every post of this job has already finished.",
"requestId": "01M2SR0913DRD5SJWP0TR3JTBD",
"details": []
}
}
That is a 409, and the channel's status is how you would have known in advance. Two moments are refused with a request to try again shortly: the second or two in which the clip is copied back from the renderer, and the instant its hand-over is being sent.
Update a post that was handed over
POST /v1/jobs/{id}/update changes the text of a channel's post once it was handed over, published
or not, and optionally the video. It hands the change over the ways the channel has switched on
(never one that is off), or those of them named in ways. Its email goes as "This post changed",
with a post kit showing the new text and, where replaced, the new video, and the webhook receives a
signed post.update: whoever posted it changes it where it is. Through the
platform connector, Reelwire changes the text of the post the
connector posted itself, where the platform lets an app do that (connectorCan); it never replaces a video there, so
to change the video on such a platform, send a delete request and post it again. Where the connector cannot, it is left
out of a request that names no ways; a request naming it, or with no other way left to go, is a 409 saying what to do
by hand. The post takes the
new text once the change has gone out, and reads UPDATING until then. On a channel with every way
switched off the change is the post's own at once, and still reaches its syndication participants.
curl http://localhost:4000/v1/jobs/01M2SQN50JQFC00PZE93M9E33R/update \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"description": "The ten-year settled at 2.41 per cent. Corrected: the high is a three-week high, not a three-month one.\n\n#bunds #rates"
}'
{ "ok": true, "queued": 1 }
The body:
| Field | What it is | Limit |
|---|---|---|
description | The whole description as it should read: the post's own words, the brand's line and the hashtags in one text. | 5000 characters |
caption | The post text on its own, where you are not sending description. | 4000 characters |
hashtags | An array of tags, with or without the #. | 60 tags, 60 characters each |
title | Where the platform has a distinct title field. | 400 characters |
disclaimer | The brand's own line. | 300 characters |
final | true when caption, hashtags and disclaimer already hold everything the post should say. | |
link | { "url": "...", "label": "..." }, or an empty url for no link. Absent keeps the link the post has. | url 500 characters, label 40 |
mediaFileId | A video from the media library to post instead of the current one. Absent keeps the video. A file that is not a video, or no longer in the library, is a 409. | |
ways | Some of the ways the channel has switched on: EMAIL, WEBHOOK, CONNECTOR. Absent is every one of them that can carry the change. A way the channel has off is a 409 naming its switch; an empty list or any other word is a 400. Syndication participants get the change whatever is chosen. | |
captions | A channel on No specific platform only: { "tiny": "...", "short": "...", "medium": "...", "long": "..." }, one text per kind of platform. The medium one is also the post's own caption. | 200, 500, 1500 and 4000 characters |
Text over a limit is refused, never cut: a caption shortened on the way out is one nobody wrote. The refusal is a 409 with a sentence naming the limit.
description is the post as it will read, and nothing of the brand's is added to it. With caption
instead, the brand's description and hashtags are added as they were when the post first went out,
unless you send final: true: then the fields are the post as it will read, and sending hashtags
without one somebody removed keeps it removed.
A change already on its way is a 409 until it has gone. So is a post that was never handed over: there is nothing out there to change yet.
On a channel that asks for approval, the change waits for a reviewer. It is checked as above, then kept on an approval request of its own, and nothing is handed over yet:
{ "ok": true, "queued": 0, "awaitingApproval": true, "approvalId": "01M3B6A2K9W4N8D1F5H7J3Q0RT" }
The request is on GET /v1/approvals with kind UPDATE, and the channel's approval on
GET /v1/jobs reads kind update. Once approved, the change is handed over the ways named here;
rejected, the post stays as it went out. See Approvals. One update waits at a time:
asking for another while one waits is a 409. A delete is never held.
Reuse
GET /v1/jobs/{id}/reuse answers with what a post was made from, to make it again with a change. It
reads and changes nothing:
{
"reuse": {
"kind": "post",
"templateId": "tpl_dd7e6b264fb8",
"templateName": "Big number",
"variant": "Corporate glass",
"version": "1.0.0",
"pinnedVersion": null,
"brandId": "01M38Z6Y4T0WTFVAX5XET1S17P",
"ratio": "9x16",
"locale": "en",
"data": { "clipData": { "label": "Points the DAX gained this week", "value": 412 }, "locales": { "de": { "clipData": { "label": "Punkte, die der DAX diese Woche zulegte" } } } },
"title": "Copy of Weekly market wrap",
"queueId": "01M38Z6Y8H5G7M4Q2X8V1R0C9D"
}
}
data is the content in the template's own shape, pictures as media:// references and
locales for every further language. A post from a post list answers with the content as it was
typed, its pinnedVersion (null is Latest) and the queueId of its list; a post from a feed or a
stream answers with the content as it was drawn and the languages the event carried, and its
queueId is null. To post it again, send templateId, variant as templateVariant,
pinnedVersion as templateVersion and data to POST /v1/queues/{id}/packages; to render only
the video, send templateId, variant, version, data, brandId, ratio and locale as they
are to POST /v1/direct-export/video. For an AI assistant it is reelwire_reuse, which reads a
rendered clip or a direct export the same way.
Repost
POST /v1/jobs/{id}/repost hands a channel's post over again, now, as a new post of its own with the
same video and the same text as the post reads now. Nothing is rendered again, and the original stays
as it is. The new post goes every way the channel has switched on, the platform connector included,
and a reviewer's approval of the original carries over. The answer's postGroupId is the new post's
channel id.
Only a channel with Webhook switched on can be reposted. A channel without it is sent again with its own resend instead (see Sending a post again), and repost there is a 409 that says so. The channel has to be live: a paused or removed channel is a 409 that says so too.
Delete
POST /v1/jobs/{id}/delete asks for a channel's post to be taken down. It hands the request over,
the ways the channel has switched on. Its email goes as "Please take this post down", naming where it
was posted where anybody said, and the webhook receives a signed post.delete: whoever posted it
takes it down. Through the platform connector, Reelwire takes down the post the connector posted
itself, where the platform lets an app do that, and reports it deleted; where the platform does not,
or the connector never posted it, the connector is left out, and a request naming it, or with no other way left to go,
is a 409 saying to take it down by hand. A body
of { "ways": ["WEBHOOK"] } sends it only some of those ways, as ways does on an update: EMAIL,
WEBHOOK and CONNECTOR, never one the channel has off (a 409); an empty list or any other word is
a 400. Without a body it goes every way the channel has switched on that can carry it, and syndication participants who
had the post get it whatever is chosen.
The channel then reads DELETE_REQUESTED until somebody says the post is gone: mark-deleted below,
Mark as deleted in the post kit or on the Posts screen, the receiver reporting deleted, or the
platform connector having taken it down. Then it reads DELETED. A delete that no way got through
after every attempt reads DELETE_FAILED and leaves the post as it was; mark-deleted is still
accepted then, for a post somebody took down by hand. Asking again while a delete is on its way is a
409; once it went, asking again sends it again.
Mark as published, skip and mark as deleted
What only a person or a receiver can know, said for one channel:
POST /v1/jobs/{id}/mark-publishedsays the post is up. The body is optional:{ "url": "https://..." }, the link to the post, up to 2000 characters, which then shows on the Posts screen and travels with any later update or delete. Accepted from handed over, skipped or failed, and while it is still being handed over;publishedAtbecomes that moment. A link that is not a web address is a 400.POST /v1/jobs/{id}/skipsays it was handed over and will not be posted. Accepted from handed over. A skipped post can still be marked as published later.POST /v1/jobs/{id}/mark-deletedsays it was taken down, usually after a delete was handed over. Accepted from handed over or published.
These three are the same report a webhook receiver makes with
POST /v1/posts/{id}/report, under the same rules. For an AI assistant they are
reelwire_report_post, with status published, skipped or deleted. Each action answers:
| Action | On success |
|---|---|
cancel | { "ok": true, "cancelledPosts": 1, "renderStopped": true } |
repost | { "ok": true, "postGroupId": "01M..." } (the new post's channel) |
update | { "ok": true, "queued": 1 } |
delete | { "ok": true, "queued": 1 } |
resend-email | { "ok": true, "count": 1 } |
resend-webhook | { "ok": true, "count": 1 } |
resend-connector | { "ok": true, "count": 1 } |
resend-participants | { "ok": true, "count": 1 } |
retry | { "ok": true, "count": 1 } |
mark-published | { "ok": true, "count": 1, "state": "PUBLISHED" } |
skip | { "ok": true, "count": 1, "state": "SKIPPED" } |
mark-deleted | { "ok": true, "count": 1, "state": "RECALLED" } |
RECALLED is the stored state the channel's status reads as DELETED. On the platforms connected by signing in, a post the
connector made also moves there by itself within a few hours of going from the platform.
On a channel on X, resend-connector is a 409 once the post went out and was reposted 2 times, and so is retry on a
deleted post through the connector, which counts as one of them. A failed post is not held to it: retry goes as often
as it takes.
Archive and unarchive
POST /v1/jobs/{id}/archive files a channel's post away: GET /v1/jobs leaves it out unless asked
for archive=archived, and the row then carries "archived": true (a post's row where every one of its
channels is). POST /v1/jobs/{id}/unarchive brings it back. Neither changes anything else: a post
still on its way carries on, and it is handed over as it would have been. Both answer
{ "ok": true, "archived": true } or false, and a 404 for an id that is not one of your channels'
posts. For an AI assistant they are reelwire_archive_post, which brings one back with archived
false, and reelwire_list_posts takes archive.
Reporting back
POST /v1/posts/{id}/report is how a webhook receiver, a script or anybody posting by hand says
what happened to one channel's post, with an API key holding posts:write:
{ "status": "published", "url": "https://www.instagram.com/reel/C9x2Lm4oQpR/", "platformPostId": "17912345678901234" }
status is published, skipped, failed (with a reason the Posts screen shows) or deleted.
url is a web address of up to 2000 characters, platformPostId up to 200 and reason up to 1000;
a body that does not fit is a 400. The id is the channel's post (postId), or the channel's id
from GET /v1/jobs, and one that is not yours is a 404. It answers with ok, postId,
postGroupId, state and permalink. Saying the same thing again is fine and keeps the newest
link; anything the post's state does not allow is a 409 naming the state.
The whole rule, state by state, is on Webhooks.
Posts handed over by email
A channel with Email switched on hands its posts over by email. When a post is due, the workspace owner and everybody holding Operations get one email for the hand-over, whatever the number of channels in it. Its subject is "Ready to post:" and the channels' names. The email itself carries neither the video nor the text: it says which post is ready for which channels, names the channels of the post that are not in it and why, and its Open the post kit button leads to the post kit. See Post a video by hand for the email and the post kit in full.
The post kit is a page at /v1/kit/{token} that needs no sign-in: the token in its address is
the credential. For each channel it has the video to play, download or share from a phone, its cover
picture, every text ready to copy (the title where there is one and the text as it goes out, or, for
a channel on No specific platform, the description in each of its four lengths, one per kind of
platform), the channel's posting settings, and Mark as published, with the link, or Skip (from a
delete's email, Mark as deleted instead). It shows the post as it reads now, so an earlier kit follows an update. Its
buttons report through POST /v1/kit/{token}/{postGroupId}/published, /skip and /deleted, under
the same rules as reporting back. Mark as deleted is for a delete's kit only, and
Mark as published and Skip for every other kit: the wrong one is a 409. A link works for fourteen
days from the email that carried it, and each email carries its own; after that the page says it
has expired (a 410), and the post is still on the Posts screen. Asked with Accept: application/json, the same address answers the kit's
facts as JSON, with expiresAt, and an expired link then answers 403 rather than the page. Each
address may ask at most 60 times a minute.
Nobody here can know whether or where it went up, so the channel's post reads HANDED_OVER until
somebody says. The email comes once, with no reminder after it.
An update and a delete go by email as well, where Email is switched on: an update with the subject "Changed:" and the heading "This post changed", a delete with "Take down:" and "Please take this post down", each with its post kit.
Sending a post again
Each way out has its own resend, and syndication one of its own, for a channel's post that was handed over, published or skipped:
POST /v1/jobs/{id}/resend-emailsends the email again, with the post as it reads now, under the subject "Sent again:". Accepted whileresendableis true and the channel has Email switched on (off is off: a 409 otherwise); a 409 as well while an update, a delete or an earlier resent email is still on its way. For an AI assistant it isreelwire_resend_postwith wayemail.POST /v1/jobs/{id}/resend-webhooksends the post'spost.publishagain to the workspace's webhook address, built as it reads now, with a newdeliveryId, so a receiver that keeps the ids it has processed acts on it again. Accepted whileresendableis true and the channel has Webhook switched on; a 409 when it is off, when the workspace has no webhook address, or while a webhook call for the post is still on its way. The post's state does not change. For an AI assistant it isreelwire_resend_postwith waywebhook.POST /v1/jobs/{id}/resend-connectorreposts it: the channel's platform connector posts the video and its text, as the post reads now, on the platform again as a new post. The earlier post stays where it is. Once the new one is up the post is reported published with its link, and a later update or delete acts on it. Accepted whileresendableis true and the channel has Platform connector switched on; a 409 when it is off, or while the connector is still working on the post. For an AI assistant it isreelwire_resend_postwith wayconnector.POST /v1/jobs/{id}/resend-participantssends the post again, as it reads now, to every syndication participant whose group takes the channel, by the ways each group allows. Meant for a channel with Email, Webhook and Platform connector all switched off, whose only readers are its participants (a channel row sayssyndicatedwhen a group with somebody signed up takes it); a 409 where none does. The post's state does not change. For an AI assistant it isreelwire_resend_postwith wayparticipants.
A channel with every way switched off hands its post over the moment it is due: it is HANDED_OVER
with no email, no webhook call and no connector post of the workspace's own (email, webhook and
connector read off). An update to it is the post's own at once, and a delete reads as
DELETE_REQUESTED as soon as it is asked for, since nothing of the workspace's own will ever be
delivered to make either so; both still reach its participants.
Repost on a channel without Webhook switched on is a 409: resending the email sends the same thing
to the same people, and resend-connector posts it on the platform again.
Trying a failed post again
POST /v1/jobs/{id}/retry tries a channel's post that failed (POST_FAILED: its email, webhook
call or platform connector never got through after every attempt, or its receiver reported it
failed) again. Its post request is handed over anew, as the post reads now, as a hand-over of its
own: the email reads "Ready to post:", the webhook call is a post.publish with a new deliveryId,
and the platform connector posts it. The post moves from FAILED back to handing over, then on as a
first hand-over would, to HANDED_OVER, or PUBLISHED where the connector posted it; if every way
fails again, it is FAILED again with the newest reason. The body is optional:
{ "ways": ["EMAIL", "WEBHOOK", "CONNECTOR"] }, some of the ways the channel has switched on, every
one of them when left out; a way it has off is a 409. Syndication participants are not sent it again,
since the first hand-over reached them. Accepted while retryable is true; a 409 otherwise, when the
channel has every way off, when Webhook is chosen and the workspace has no webhook address, or while
the post is already being tried again. On the Posts screen it is Resend post request on a failed
post. For an AI assistant it is reelwire_retry_post.
A deleted post goes the same way, in case it was deleted by mistake. While its status reads DELETED, its clip is
still stored and no delete is still on its way, retryable is true, and retry hands it over as a new post. The post
moves from RECALLED back to handing over, its platformPostId and permalink are cleared, and the delete no longer
counts. Unlike a failed post, its syndication participants are sent it again, since they were told to take it down, and a
channel that reaches only them goes straight to HANDED_OVER. It is a 409 while a delete request is still on its way.
Rendering a failed clip again
POST /v1/jobs/{id}/retry-render renders a channel's clip again after its render failed
(RENDER_FAILED: the renderer reported a failure, or the render did not finish in time). It takes no
body. The same render job goes back in line with attempt one higher and is drawn from the post's
content, brand and template as they are now, so a template or brand fixed in the meantime takes
effect. Once the clip is stored the post is made from it and goes on as it would have the first time:
AWAITING_APPROVAL where the channel asks for a reviewer, otherwise SCHEDULED, then handed over when
due, as a hand-over of its own if the post's other channels went out without it. It answers
{ "ok": true, "renderJobId": "...", "attempt": 2, "counted": false }. It costs no video, since the
attempt that failed was counted already, except where that attempt was refused because no videos were
left (counted is then true, and it is a 409 while there is still none). Accepted while
renderRetryable is true on the channel; a 409 otherwise, when the channel is paused (resume it
first), or while the render is already being tried again; a 404 for a post that is not there. On the
Posts screen it is Retry rendering under More. For an AI assistant it is reelwire_retry_post with
render true.
The posting calendar
GET /v1/calendar draws what is expected to post in a window: the channels, their scheduled feeds
and the manual posts waiting. from and to are required ISO instants, the window is at most
367 days (a year ahead from now fits), and all times are UTC. It needs schedule:read, a permission
of its own, so a key that reads the calendar cannot also act on posts.
curl "http://localhost:4000/v1/calendar?from=2026-09-18T00:00:00Z&to=2026-09-19T00:00:00Z" \
-H "Authorization: Bearer rw_your_key_here"
Pass channelIds as a comma-separated list to narrow it. Empty means every channel. kind keeps
only the ready-made feeds (BROADCAST) or only the post lists (QUEUE); all is the default. A
window longer than 367 days, to before from, either one missing or not a date, or another kind
is a 400.
The answer is a plan, not a promise: a feed posts when its data arrives, and these are the moments
it is expected to. channels describes every channel once (brand, with brandLogo a link to its logo or null and brandLogoOnDark
one to its logo for dark backgrounds or null, platform, shape, language, approval). entries is one line per expected post, each with its channelId, the source and its
group, the moment at, allDay where a feed's schedule names a day but no time, and a
scheduleLabel; a post list's posts are entries at their exact times, their label saying whether
each is scheduled, sent or failed to go. unscheduled holds what a channel listens to that has no
time to draw: a feed described rather than timed, or a custom feed, which posts whenever your system
sends to it. Paused channels and paused sources post nothing, so they are left out.
Each entry also says what it is drawn with: templateId, variant and version (null for the
newest build), feedId on a ready-made feed's, and packageId and state on a post list's.
detail=posts adds templateName to every entry and, on a post list's, post: what the post says,
as the Schedule's list shows it.
{
"kind": "QUEUE",
"at": "2026-10-26T15:00:00.000Z",
"templateName": "Text card",
"variant": "Bento grid",
"post": {
"title": "Real ticks and real spreads for MetaTrader 5",
"kicker": "MT5 Tick Data",
"lines": [
{ "label": null, "text": "Real ticks. Real spreads. Real results.", "extras": [{ "label": "Words to mark", "text": "Real spreads." }] },
{ "label": "Attribution", "text": "FEA Trading" }
],
"caption": "Tick history for the MetaTrader 5 Strategy Tester, updated every month.",
"hashtags": ["metatrader5"]
}
}
lines follow the template's own order and carry its labels; the look, pictures and videos are left
out. caption and hashtags are what the channel posts beside the clip, the caption in the length
its platform takes.
A preview of one post
GET /v1/calendar/still?channelId=...&subscriptionId=...&packageId=... answers one PNG: a post
list's post drawn with its own words in its channel's brand, shape and language, a frame from the
last third of the clip. Without packageId, for a custom feed, it draws the template's example
content instead, since what your system will send is not known yet. It is not counted against your
videos and is kept nowhere; an unchanged post answers 304 to the ETag you already hold. A
ready-made feed is previewed with its own clips, GET /v1/feeds/{feedId}/reference/{ratio}.
The schedule as a page to download
GET /v1/calendar/export takes the same from, to, channelIds and kind, its window up to five
years (1,831 days) rather than 367 days, and answers one HTML
page with a table of every post in the window (what each post list post says, each feed's rule) and
the sources without a precise schedule under it, in the language of whoever asks, as an attachment.
It opens in any browser and prints; nothing in it is fetched from anywhere. With format=pdf it
answers the same as a PDF on landscape A4 instead (format=html, the default, is the page). With
timeZone, an IANA name such as Europe/Berlin, its times are on that clock; left out, they are on a
signed-in person's own, and in UTC for a key.
curl -OJ "http://localhost:4000/v1/calendar/export?from=2026-10-24T00:00:00Z&to=2026-11-23T23:59:59Z" \
-H "Authorization: Bearer rw_your_key_here"
curl -OJ "http://localhost:4000/v1/calendar/export?from=2026-10-24T00:00:00Z&to=2026-11-23T23:59:59Z&format=pdf" \
-H "Authorization: Bearer rw_your_key_here"
GET /v1/calendar/export/link answers { url, expiresAt, from, to } instead: a link to the same
page, or with format=pdf to the PDF, that works without a credential for two hours, for handing
to a person, which is what the AI assistant's reelwire_calendar with download does (and format
pdf for the PDF). The window, channels, kind, language, format and timeZone are signed into it, so an
edited link is refused.
With stills of every post
Three stills of each post, from its start, middle and keyframe, drawn in its channel's brand, take a
few seconds each, so that file is made as a job. POST /v1/calendar/export/jobs takes from, to
(the same five years as the page),
channelIds (a list), kind, format, timeZone and stills: true and answers 202 { jobId }.
GET /v1/calendar/export/jobs/{jobId} answers { jobId, state, done, total, ahead, error, format, stills, from, to, kind, channels, askedAt, keptUntil, fileName }, state being queued (with ahead jobs
before it), working, done or failed and done of total the stills drawn so far; once it is
done, GET /v1/calendar/export/jobs/{jobId}/file is the file. A workspace's jobs are a queue, made
one after the other, at most five waiting or being made at once (a sixth is a 409).
GET /v1/calendar/export/jobs lists yours, in the order you asked for them, as { jobs: [...] }, and
POST /v1/calendar/export/jobs/{jobId}/cancel cancels one that waits or is made, or takes a finished
one off the list. Every one of these answers only the jobs the same key or person asked for. Stills
are drawn for every post until to, however many, a feed posting every day once, so a long window can
take hours. A PDF whose stills are too many for one file, about a thousand pictures, is made as several
PDFs, each a part of the schedule in order, in one zip file (application/zip, its
fileName ending in .zip). A finished job is kept an hour from when it finished, or as long as a
link to it works, until keptUntil, then forgotten; one that waits or is being made is kept until it finishes. An update or a
restart of Reelwire loses no job: one that was being made is made again from the start.
GET /v1/calendar/export/link?jobId={jobId} answers a link to a finished job's file, { url, expiresAt, from, to, fileName }, which works without a credential for two hours, the job kept as long as it does; a job
still being made is a 409. For an AI assistant it is reelwire_calendar with download and stills,
then exportJobId to ask how far it is and hand the file over.
curl -X POST http://localhost:4000/v1/calendar/export/jobs \
-H "Authorization: Bearer rw_your_key_here" -H "Content-Type: application/json" \
-d '{"from":"2026-10-24T00:00:00Z","to":"2026-11-23T23:59:59Z","format":"pdf","stills":true}'
Downloading a post
GET /v1/jobs/{id}/download and GET /v1/posts/{id}/package both return a ZIP, not JSON:
HTTP/1.1 200 OK
content-type: application/zip
content-disposition: attachment; filename="2026-09-18--07-46-28 - Brand 1 - Channel 10 en - Instagram Reels - 9x16.zip"
content-length: 741635
x-request-id: 01M2SR0G3PCP3E63SZ6DZBZBJ0
They are for different readers:
/v1/jobs/{id}/downloadis for a person, and takes a channel'sid. It holds the video and one HTML page that shows the post as its post kit does: brand, format, status and times, the clip, and every text to paste under its own label with a Copy button. A channel on No specific platform gets the text for each kind of platform. The page works offline and can be forwarded as it is. Where there is no video, because the render is still running, failed, or the clip was removed to make storage room, the page says why where the clip would be./v1/posts/{id}/packageis for a machine, and takes a channel's post id. It holds the video, a small page that plays it, and a JSON of the post:meta(created, status, brand, channel, platform, approval, published) anddescription(language, aspect ratio, title, caption, brand description, link, hashtags, andtext, all of them run together as they are posted). A post whose clip is no longer stored is a 404, because there is nothing to package.
Pass ?tz=Europe/Berlin to have the times in the file names and inside the files in your own zone.
The video is the one the post shows now: a replacement from the media library where an update put
one, otherwise the rendered clip.
Mistakes people make
Polling /v1/jobs in a tight loop. Use /v1/pipeline and honour pollMs, or take the
webhook and stop polling.
Storing an asset URL. They are signed and expire within the hour. Keep the assetId and ask
again.
Assuming a post is one channel. A post goes to every channel that listens to its source, and its channels can be in different states: one published, one waiting for a reviewer.
Acting on the post's id. The actions take a channel's id from channels, never the row's
id, which is the post's postKey.
Calling an action and handling the 409 as an error path. updatable, resendable, retryable,
markable, skippable, deletable, markDeletable and repostable on the channel say in advance.
Reading HANDED_OVER as published. It means a way out delivered it to a person or a receiver.
PUBLISHED comes when somebody says so, or when the channel's platform connector posted it.
Expecting /v1/posts/{id}/package to return JSON. It is a ZIP with a JSON inside it.
Treating stage as a status. It is a group of statuses. Filter on stage, read status.