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.
| Method | Path | Permission |
|---|---|---|
GET | /v1/jobs | posts:read |
POST | /v1/jobs/{id}/cancel | posts:write |
POST | /v1/jobs/{id}/repost | posts:write |
POST | /v1/jobs/{id}/update | posts:write |
POST | /v1/jobs/{id}/resend-email | posts:write |
POST | /v1/jobs/{id}/resend-webhook | posts:write |
POST | /v1/jobs/{id}/mark-published | posts:write |
POST | /v1/jobs/{id}/skip | posts:write |
POST | /v1/jobs/{id}/mark-deleted | posts:write |
POST | /v1/jobs/{id}/archive | posts:write |
POST | /v1/jobs/{id}/unarchive | posts:write |
POST | /v1/jobs/{id}/delete | posts:write |
GET | /v1/jobs/{id}/download | posts:read |
GET | /v1/jobs/{id}/reuse | posts:read |
GET | /v1/posts | posts:read |
POST | /v1/posts/{id}/report | posts:write |
GET | /v1/posts/{id}/package | posts:read |
GET | /v1/pipeline | posts:read |
GET | /v1/pipeline/{id} | posts:read |
GET | /v1/overview | posts:read |
GET | /v1/calendar | schedule:read |
GET | /v1/approvals | approvals:read |
POST | /v1/approvals/{id} | approvals:write |
List posts
GET /v1/jobs lists the posts: one row per post, its channels inside it. It pages, searches and
filters like every log; see Lists and paging. With by=channel it gives
one row per channel instead, which is what the Posts screen shows: see Filters.
curl "http://localhost:4000/v1/jobs?perPage=1" \
-H "Authorization: Bearer rw_your_key_here"
This is the post from the worked example on Sending events, for two channels: Channel 4 de was handed over by webhook and reported published, and Channel 10 en waits for a reviewer.
{
"jobs": [
{
"id": "01M2SQN507TDA0KNTJJABHBR9A",
"postKey": "01M2SQN507TDA0KNTJJABHBR9A",
"status": "AWAITING_APPROVAL",
"summary": "1 of 2 published",
"progress": 0,
"priority": false,
"createdAt": "2026-09-18T07:45:50.099Z",
"updatedAt": "2026-09-18T07:47:02.316Z",
"origin": "custom",
"details": "Enterprise 2 Stream 1",
"postTitle": null,
"brandName": "Brand 1",
"channelCount": 2,
"channelNames": ["Channel 4 de", "Channel 10 en"],
"caption": "The ten-year settled at 2.41 per cent.",
"title": "Bund yields close at a three-week high",
"email": "off",
"webhook": "partly",
"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:
statusis the one of its channels that most needs somebody. See A post's status.summarysays how far a post of several channels is, in words: "2 of 3 published", "1 of 3 handed over", or "3 channels". It isnullfor a post of one channel.emailandwebhooksay how each way out went, over its channels:off,waiting(not handed over yet),sending,retrying,sent,failed, orpartlywhere its channels went at different times, as a channel with a reviewer follows the others on its own. A channel carries the same values for itself, withoutpartly.approvalIdis the waiting review where exactly one channel waits, andapproval.waitinghow many channels wait.captionandtitleare its first channel's, anddownloadablesays whether any channel has a video to give.
What a channel says about itself:
statusis worked out from its render and its post. See Statuses and stages.stateis its post's stored state, asGET /v1/postshas it, ornullbefore there is a post.deliverByEmailanddeliverByWebhookare the channel's two switches as they are now.handedOveris true once it was handed over, whatever was said about it since, anddeleteRequestedwhile a delete was handed over and nobody has said the post is gone.publishedAtis when it was handed over, until somebody reports it published; then it is when they did.permalinkis the link to the post, where somebody reported one.erroris why it stopped, where it did: the render's reason, or the post's.
The flags tell you which action a channel will accept right now. Read them rather than guessing from the status, and you will not have to handle a 409:
| Flag | Action | True while |
|---|---|---|
updatable | update | It was handed over (handed over, published or skipped), and no change or delete is on its way. |
resendable | resend-email, resend-webhook | It is handed over, published or skipped, and nothing else is on its way; each also needs its way switched on on the channel. |
markable | mark-published | It is handed over, skipped or failed, and nobody asked for it to be taken down. |
skippable | skip | It is handed over, and nobody has said anything about it yet. |
deletable | delete | It is handed over or published, and no change or delete is on its way. |
markDeletable | mark-deleted | A delete was handed over, and nobody has said the post is gone. |
repostable | repost | It was handed over once, and its channel is live with Webhook switched on. |
downloadable | download | Its 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
| Parameter | Values |
|---|---|
stage | rendering, approval, scheduled, publishing, handedOver, published, skipped, failed, rejected, cancelled, deleted |
channelId | A channel id |
brandId | A brand id |
platform | The channel's format: instagram, tiktok, youtube, facebook, linkedin, telegram or download, which is No specific platform |
origin | shipped, custom, manual |
approval | pending, approved, rejected, none |
by | channel: one row per channel of a post rather than one per post |
archive | archived: only what somebody archived. Without it, only what nobody did |
A post is listed when one of its channels matches, and then with every one of its channels.
With by=channel, each channel is a row of its own: channels holds that one channel, the row's
id is the channel's id (the one every action below takes), and postKey still names its post.
The filters, the search and the stage counts then count channels. How a post is handed over does not
change: the channels of one post that go together still go in one email and one webhook call.
Statuses and stages
A channel's status is worked out, not stored: its render's state until the clip exists, then its
post's, with an update or a delete on its way laid over it. A stage is a set of statuses, not a
status:
| Stage | Channel statuses |
|---|---|
rendering | ACCEPTED, AWAITING_RENDERING, RENDERING, DOWNLOADING |
approval | AWAITING_APPROVAL |
scheduled | SCHEDULED |
publishing | HANDING_OVER, UPDATING |
handedOver | HANDED_OVER |
published | PUBLISHED |
skipped | SKIPPED |
failed | RENDER_FAILED, POST_FAILED, UPDATE_FAILED, DELETE_FAILED |
rejected | REJECTED |
cancelled | CANCELLED, REPLACED |
deleted | DELETE_REQUESTED, DELETED |
publishing is being handed over right now, and handedOver is handed over and not reported on
yet. The old stage emailed, from when only an email could hand a post over, still works and means
handedOver.
HANDED_OVER is not published: it means a way out delivered the post, and nobody has said yet
whether it went up. PUBLISHED comes only when somebody says so. 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:
| Field | What it is | Limit |
|---|---|---|
description | The whole description as it should read: the post's own words, the brand's line and the hashtags in one text. | 5000 characters |
caption | The post text on its own, where you are not sending description. | 4000 characters |
hashtags | An array of tags, with or without the #. | 60 tags, 60 characters each |
title | Where the platform has a distinct title field. | 400 characters |
disclaimer | The brand's own line. | 300 characters |
final | true when caption, hashtags and disclaimer already hold everything the post should say. | |
link | { "url": "...", "label": "..." }, or an empty url for no link. Absent keeps the link the post has. | url 500 characters, label 40 |
mediaFileId | A video from the media library to post instead of the current one. Absent keeps the video. | |
captions | A 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-publishedsays the post is up. The body is optional:{ "url": "https://..." }, the link to the post, up to 2000 characters, which then shows on the Posts screen and travels with any later update or delete. Accepted from handed over, skipped or failed;publishedAtbecomes that moment.POST /v1/jobs/{id}/skipsays it was handed over and will not be posted. Accepted from handed over. A skipped post can still be marked as published later.POST /v1/jobs/{id}/mark-deletedsays it was taken down 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:
| Action | On success |
|---|---|
cancel | { "ok": true, "cancelledPosts": 1, "renderStopped": true } |
repost | { "ok": true, "postGroupId": "01M..." } (the new post's channel) |
update | { "ok": true, "queued": 1 } |
delete | { "ok": true, "queued": 1 } |
resend-email | { "ok": true, "count": 1 } |
resend-webhook | { "ok": true, "count": 1 } |
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-emailsends the letter, with the post as it reads now, headed "Sent again". Accepted whileresendableis 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-webhooksends the post'spost.publishagain to the workspace's webhook address, built as it reads now, with a newdeliveryId, so a receiver that keeps the ids it has processed acts on it again. Accepted whileresendableis true and the channel has Webhook switched on; a 409 when it is off, when the workspace has no webhook address, or while a webhook call for the post is still on its way. The post's state does not change. For an AI assistant it isreelwire_resend_post_webhook.POST /v1/jobs/{id}/updatesends a letter headed "Changed", with the new text and, where it was replaced, the new video.POST /v1/jobs/{id}/deletesends 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}/downloadis for a person, and takes a channel'sid. It holds the video and one HTML page that shows the post as its post kit does: brand, format, status and times, the clip, and every text to paste under its own label with a Copy button. A channel on No specific platform gets the text for each kind of platform. The page works offline and can be forwarded as it is. Where there is no video, because the render is still running, failed, or the clip was removed to make storage room, the page says why where the clip would be./v1/posts/{id}/packageis for a machine, and takes a channel's post id. It holds the video, a small page that plays it, and a JSON of the post:meta(created, status, brand, channel, platform, approval, published) anddescription(language, aspect ratio, title, caption, brand description, link, hashtags, andtext, all of them run together as they are posted). A post whose clip is no longer stored is a 404, because there is nothing to package.
Pass ?tz=Europe/Berlin to have the times in the file names and inside the files in your own zone.
The video is the one the post shows now: a replacement from the media library where an update put
one, otherwise the rendered clip.
Mistakes people make
Polling /v1/jobs in a tight loop. Use /v1/pipeline and honour pollMs, or take the
webhook and stop polling.
Storing an asset URL. They are signed and expire within the hour. Keep the assetId and ask
again.
Assuming a post is one channel. A post goes to every channel that listens to its source, and its channels can be in different states: one published, one waiting for a reviewer.
Acting on the post's id. The actions take a channel's id from channels, never the row's
id, which is the post's postKey.
Calling an action and handling the 409 as an error path. updatable, resendable, 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.