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 and posts nothing itself. When a post is due it is handed over, by email, by webhook or both, as each channel says, and whoever receives it posts it and then says so. See Webhooks for the webhook, and Posts handed over by email below for the email.

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}/resend-emailposts:write
POST/v1/jobs/{id}/resend-webhookposts: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}/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/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",
"approvalId": "01M2SQPASCPHZ65QRPX9X628GD",
"approval": { "required": true, "state": "PENDING", "login": null, "name": null, "waiting": 1 },
"downloadable": true,
"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": "Template 1",
"templateVariant": "Standard",
"templateVersion": "1.0.0",
"renderJobId": "01M2SQN51TGR960ZKWNVKH1HQJ",
"deliverByEmail": false,
"deliverByWebhook": true,
"email": "off",
"webhook": "sent",
"handedOver": true,
"deleteRequested": false,
"updatable": true,
"resendable": false,
"markable": false,
"skippable": false,
"deletable": true,
"markDeletable": false,
"repostable": true,
"downloadable": true,
"priority": true,
"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": "Template 1",
"templateVariant": "Standard",
"templateVersion": "1.0.0",
"renderJobId": "01M2SQN51XWQ3D8E0T4B6RZK2M",
"deliverByEmail": false,
"deliverByWebhook": true,
"email": "off",
"webhook": "waiting",
"handedOver": false,
"deleteRequested": false,
"updatable": false,
"resendable": false,
"markable": false,
"skippable": false,
"deletable": false,
"markDeletable": false,
"repostable": false,
"downloadable": true,
"priority": true,
"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"],
"approval": ["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 and webhook 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.
  • 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, and downloadable says whether any channel has a video to give.

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.
  • deliverByEmail and deliverByWebhook are the channel's two switches as they are now.
  • 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; then it is when they did. permalink is the link to the post, where somebody reported one.
  • 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-webhookIt is handed over, published or skipped, and nothing else is on its way; each also needs its way switched on on the channel.
markablemark-publishedIt is handed over, skipped or failed, and nobody asked for it to be taken down.
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.
repostablerepostIt was handed over once, and its channel is live with Webhook switched on.
downloadabledownloadIts video is still stored.

The facets and labels blocks are read from the rows the workspace actually holds, not from a fixed list, so a filter value 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: instagram, tiktok, youtube, facebook, linkedin, telegram or download, which is No specific platform
originshipped, custom, manual
approvalpending, 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

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. 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.

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,
"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, 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), 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 said so), REPLACED and CANCELLED.

clipStored: false means the video has been removed to make room in the workspace's storage allowance. The post is still a record of what went out; there is no file left to download.

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_a9c3fdb1b5c9",
"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 and by webhook.

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.

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_a9c3fdb1b5c9",
"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.

Decide it with POST /v1/approvals/{id}. The body takes state, which is APPROVED or REJECTED and defaults to APPROVED, and an optional note.

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 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.

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.

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. Reelwire changes nothing where the post is: it hands the change over, the ways the channel has switched on (never one that is off). Its email goes as "This post changed", with the new text and, where replaced, the new video, and the webhook receives a signed post.update. The post takes the new text once the change has gone out, and reads UPDATING until then.

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.
captionsA channel on No specific platform only: { "short": "...", "medium": "...", "long": "..." }, one text per kind of platform. The medium one is also the post's own caption.500, 1500 and 4000 characters

Text over a limit is refused, never cut: a caption shortened on the way out is one nobody wrote.

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.

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_a9c3fdb1b5c9",
"templateName": "Template 1",
"variant": "Standard",
"version": "1.0.0",
"pinnedVersion": null,
"brandId": "01M38Z6Y4T0WTFVAX5XET1S17P",
"ratio": "9x16",
"locale": "en",
"data": { "clipData": { "headline": "Markets close the week higher" }, "locales": { "de": { "clipData": { "headline": "Die Märkte schließen die Woche höher" } } } },
"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 them with brandId, ratio and locale to POST /v1/direct-export/video.

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 answer's postGroupId is the new post's channel id.

Only a channel with Webhook switched on can be reposted. A channel handed over by email only is sent again with resend-email instead, 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. Reelwire cannot delete it itself, because it posted nothing: 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.

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, or the receiver reporting deleted. Then it reads DELETED. A delete that no way got through after every attempt reads DELETE_FAILED and leaves the post as it was. 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; publishedAt becomes that moment.
  • 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 after a delete was handed over.

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_mark_post_published, reelwire_skip_post and reelwire_mark_post_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 }
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.

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 and reelwire_unarchive_post, 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. The id is the channel's post (postId), or the channel's id from GET /v1/jobs. 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 as letters. When a post is due, the workspace owner and everybody holding Operations get one letter for the hand-over, whatever the number of channels in it. Its subject is "Ready to post:" and the channels' names. It has a section per channel with Email switched on: its format, shape and language, the cover picture, a link to download the video, and the texts ready to copy, which are the title where there is one and the text as it goes out, or, for a channel on No specific platform, the caption in three lengths, one per kind of platform. It names the channels of the post that are not in it and why, and its Open the post kit button leads to a page, needing no sign-in, where each channel's video can be played, downloaded or shared from a phone, every text copied, and the post marked as published, with its link, or skipped. See Post a video by hand for the letter and the post kit in full.

Nobody here can know whether or where it went up, so the channel's post reads HANDED_OVER until somebody says. A day after the letter, if a channel of it is still handed over and nobody has said anything, the same people get one short reminder, "Still to post:", with the same post kit.

Three actions belong to the email in particular:

  • POST /v1/jobs/{id}/resend-email sends the letter, with the post as it reads now, headed "Sent again". Accepted while resendable is true and the channel has Email switched on (off is off: a 409 otherwise); a second one while a change is on its way is a 409.
  • 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_webhook.
  • POST /v1/jobs/{id}/update sends a letter headed "Changed", with the new text and, where it was replaced, the new video.
  • POST /v1/jobs/{id}/delete sends a letter headed "Take down", asking whoever posted it to take it down.

Repost on a channel handed over by email only is a 409: resending the email sends the same thing to the same people.

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 62 days, 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.

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, 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.

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.