Skip to main content

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.

MethodPathPermission
GET/v1/jobsposts:read
POST/v1/jobs/{id}/cancelposts:write
POST/v1/jobs/{id}/repostposts:write
POST/v1/jobs/{id}/updateposts:write
POST/v1/jobs/{id}/commentsposts:write
PATCH/v1/jobs/{id}/comments/{commentId}posts:write
POST/v1/jobs/{id}/comments/{commentId}/deleteposts:write
POST/v1/jobs/{id}/resend-emailposts:write
POST/v1/jobs/{id}/resend-webhookposts:write
POST/v1/jobs/{id}/resend-connectorposts:write
POST/v1/jobs/{id}/resend-participantsposts:write
POST/v1/jobs/{id}/retryposts:write
POST/v1/jobs/{id}/retry-renderposts:write
POST/v1/jobs/{id}/mark-publishedposts:write
POST/v1/jobs/{id}/skipposts:write
POST/v1/jobs/{id}/mark-deletedposts:write
POST/v1/jobs/{id}/kit-linkposts:write
POST/v1/jobs/{id}/archiveposts:write
POST/v1/jobs/{id}/unarchiveposts:write
POST/v1/jobs/{id}/deleteposts:write
GET/v1/jobs/{id}/downloadposts:read
GET/v1/jobs/{id}/reuseposts:read
GET/v1/postsposts:read
POST/v1/posts/{id}/reportposts:write
GET/v1/posts/{id}/packageposts:read
GET/v1/pipelineposts:read
GET/v1/pipeline/{id}posts:read
GET/v1/overviewposts:read
GET/v1/calendarschedule:read
GET/v1/calendar/stillschedule:read
GET/v1/calendar/exportschedule:read
GET/v1/calendar/export/linkschedule:read
GET/v1/approvalsapprovals: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:

  • status is the one of its channels that most needs somebody. See A post's status.
  • summary says how far a post of several channels is, in words: "2 of 3 published", "1 of 3 handed over", or "3 channels". It is null for a post of one channel.
  • email, webhook and connector say how each way out went, over its channels: off, waiting (not handed over yet), sending, retrying, sent, failed, or partly where 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, without partly. syndication says the same of the post's syndication participants, off where none take it, and a channel carries it too, partly there where some participants were reached and some not.
  • approvalId is the waiting review where exactly one channel waits, and approval.waiting how many channels wait.
  • caption and title are its first channel's, downloadable says whether any channel has a video to give, and archived is true where every one of its channels was archived.

What a channel says about itself:

  • status is worked out from its render and its post. See Statuses and stages.
  • state is its post's stored state, as GET /v1/posts has it, or null before there is a post.
  • caption and title are its post's own text. Where the content brought its text under metaData, the caption comes in four lengths, captionTiny (200 characters), captionShort (500), captionMedium (1,500) and captionLong (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 with captions.
  • deliverByEmail, deliverByWebhook and deliverByConnector are the channel's switches as they are now, and email, webhook and connector what each way did for it: off, waiting, sending, retrying, sent or failed. For the connector, sent is posted on the platform.
  • connectorCan is what the channel's platform connector can do with this post: update and delete whether its platform lets it change or take down a post, updateTitle whether that change takes the title too, which only YouTube's does, posted whether the connector posted this one (neither works otherwise), refusesSameText whether the platform refuses text it already has, so resend-connector only goes through there once the text has changed, and platform the platform's name.
  • syndicated is 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.
  • handedOver is true once it was handed over, whatever was said about it since, and deleteRequested while a delete was handed over and nobody has said the post is gone.
  • publishedAt is when it was handed over, until somebody reports it published (or the platform connector posted it); then it is when that happened. permalink is the link to the post, where somebody reported one or the connector posted it.
  • approval says whether it needs a reviewer and where that stands: state is PENDING, APPROVED, REJECTED, CANCELLED, UPCOMING while a channel that asks for approval is still rendering the clip, or null where nobody is asked; login and name say who decided. kind is post, or update for a change to a post that went out, which carries the change asked for; needed is how many approvals it takes (2 where an Enterprise channel asks for two approvers), first who gave the first of two while the second is awaited, and requestedBy who wrote the post or asked for the change, each as login and name.
  • archived is true once somebody archived it. See Archive and unarchive.
  • error is 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:

FlagActionTrue while
updatableupdateIt was handed over (handed over, published or skipped), and no change or delete is on its way.
resendableresend-email, resend-webhook, resend-connector, resend-participantsIt 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).
retryableretryIt 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.
renderRetryableretry-renderIts clip could not be rendered (RENDER_FAILED), and its channel was not removed. renderRetryCounts says whether the retry takes a video.
markablemark-publishedIt is handed over or skipped, and nobody asked for it to be taken down. A failed post is tried again with retry instead.
skippableskipIt is handed over, and nobody has said anything about it yet.
deletabledeleteIt is handed over or published, and no change or delete is on its way.
markDeletablemark-deletedA 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).
repostablerepostIt was handed over once, and its channel is live with Webhook switched on.
downloadabledownloadIts 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​

ParameterValues
stagerendering, approval, scheduled, publishing, handedOver, published, skipped, failed, rejected, cancelled, deleted
channelIdA channel id
brandIdA brand id
platformThe channel's format: youtube, facebook, instagram, tiktok, linkedin, telegram, discord, slack, x, pinterest, threads, or download, which is No specific platform
originshipped, custom, manual
approvalupcoming (a channel that asks for approval, its clip still rendering), pending, approved, rejected, none
bychannel: one row per channel of a post rather than one per post
archivearchived: 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:

StageChannel statuses
renderingACCEPTED, AWAITING_RENDERING, RENDERING, DOWNLOADING
approvalAWAITING_APPROVAL
scheduledSCHEDULED
publishingHANDING_OVER, UPDATING
handedOverHANDED_OVER
publishedPUBLISHED
skippedSKIPPED
failedRENDER_FAILED, POST_FAILED, UPDATE_FAILED, DELETE_FAILED
rejectedREJECTED
cancelledCANCELLED, REPLACED
deletedDELETE_REQUESTED, DELETED

A post goes from rendered to scheduled, waits for approval where its channel asks, is handed over, and is reported published or skipped by the post kit, the API, the Posts screen, an assistant or a connector

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, or UPDATE, a change to a post that went out. An UPDATE carries change, 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, or 2 where the channel asks for two approvers (Enterprise). firstBy names who gave the first of two while it waits for the second, and is null otherwise.
  • authorMayApprove: whether whoever is named in requestedBy may 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:

FieldWhat it isLimit
descriptionThe whole description as it should read: the post's own words, the brand's line and the hashtags in one text.5000 characters
captionThe post text on its own, where you are not sending description.4000 characters
hashtagsAn array of tags, with or without the #.60 tags, 60 characters each
titleWhere the platform has a distinct title field.400 characters
disclaimerThe brand's own line.300 characters
finaltrue 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
mediaFileIdA 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.
waysSome 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.
captionsA 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-published says 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; publishedAt becomes that moment. A link that is not a web address is a 400.
  • POST /v1/jobs/{id}/skip says 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-deleted says 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:

ActionOn 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-email sends the email again, with the post as it reads now, under the subject "Sent again:". Accepted while resendable is 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 is reelwire_resend_post with way email.
  • POST /v1/jobs/{id}/resend-webhook sends the post's post.publish again to the workspace's webhook address, built as it reads now, with a new deliveryId, so a receiver that keeps the ids it has processed acts on it again. Accepted while resendable is 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 is reelwire_resend_post with way webhook.
  • POST /v1/jobs/{id}/resend-connector reposts 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 while resendable is 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 is reelwire_resend_post with way connector.
  • POST /v1/jobs/{id}/resend-participants sends 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 says syndicated when 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 is reelwire_resend_post with way participants.

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}/download is for a person, and takes a channel's id. 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}/package is 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) and description (language, aspect ratio, title, caption, brand description, link, hashtags, and text, 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.