Posts and jobs
One event becomes one job per channel that subscribed to it, and each job holds the render and the posts that went out from it. A job is the thing you watch and act on: cancel it, repost it, change its text, take it down. A post is one publication on one channel, in one language and one shape, and is what you read for the record of what actually happened.
| 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}/delete | posts:write |
GET | /v1/jobs/{id}/download | posts:read |
GET | /v1/posts | posts:read |
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 | posts:read |
GET | /v1/approvals | approvals:read |
POST | /v1/approvals/{id} | approvals:write |
List jobs
GET /v1/jobs is the Posts screen's own query. It pages, searches and filters like every log; see
Lists and paging.
curl "http://localhost:4000/v1/jobs?perPage=1" \
-H "Authorization: Bearer rw_your_key_here"
{
"jobs": [
{
"id": "01M2SQN50JQFC00PZE93M9E33R",
"status": "PUBLISHED",
"progress": 1,
"createdAt": "2026-09-18T07:45:50.099Z",
"updatedAt": "2026-09-18T07:46:30.722Z",
"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:46:30.717Z",
"permalink": "http://localhost:4300/p/instagram_f7hp75jnx2m9",
"scheduledFor": "2026-09-18T07:46:28.711Z",
"error": null,
"templateName": "Template 1",
"templateVariant": "Standard",
"templateVersion": "1.0.0",
"renderJobId": "01M2SQN51TGR960ZKWNVKH1HQJ",
"updatable": true,
"repostable": true,
"deletable": 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=1789721198&sig=3b039a1edf0c2887301620e41b0ab54a05988a1b00e0f30b0dd06dcd46b5a0bd"
}
}
],
"total": 7,
"all": 7,
"page": 1,
"perPage": 1,
"stages": {
"rendering": 0,
"approval": 1,
"scheduled": 0,
"publishing": 0,
"published": 6,
"failed": 0,
"rejected": 0,
"cancelled": 0,
"deleted": 0
},
"unstaged": 7,
"facets": {
"stage": ["rendering", "approval", "scheduled", "publishing", "published", "failed", "rejected", "cancelled", "deleted"],
"approval": ["pending", "approved", "rejected", "none"],
"platform": ["instagram", "tiktok"],
"origin": ["shipped", "custom"]
},
"labels": {
"channelId": { "01M2985NX4V0WA1GV9MC63DEPJ": "Channel 4 de" },
"brandId": { "01M1VFRWE7VHP3H54ESRJQ0SAJ": "Brand 1" }
}
}
updatable, repostable and deletable tell you which of the actions below this job will accept
right now. Read them rather than guessing from the status, and you will not have to handle a 409.
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, published, failed, rejected, cancelled, deleted |
channelId | A channel id |
brandId | A brand id |
platform | instagram, tiktok, youtube, facebook, linkedin, telegram, and so on |
origin | shipped, custom, manual |
approval | pending, approved, rejected, none |
A stage is a set of statuses, not a status. rendering covers ACCEPTED, AWAITING_RENDERING,
RENDERING and DOWNLOADING; failed covers RENDER_FAILED, POST_FAILED, DELETE_FAILED and
UPDATE_FAILED.
The period a job falls in is when something happened to it, not only when its content arrived:
since and until match a job that arrived, rendered, or whose posts changed inside the window. A
job that arrived yesterday and published ten minutes ago is in the last hour.
List posts
GET /v1/posts is the same log at the level of one publication.
curl "http://localhost:4000/v1/posts?perPage=1" \
-H "Authorization: Bearer rw_your_key_here"
{
"posts": [
{
"id": "01M2SQPARBQPG1BRENTHBM1W08",
"postGroupId": "01M2SQN50TAP4BXAYDZRC9WMBC",
"origin": "custom",
"details": "Enterprise 2 Stream 1",
"postTitle": null,
"state": "PUBLISHED",
"platform": "instagram",
"surfaceLabel": "Instagram Reels",
"channelName": "Channel 10 en",
"brandName": "Brand 1",
"locale": "en",
"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.748Z",
"scheduledFor": "2026-09-18T07:50:17.386Z",
"publishedAt": "2026-09-18T07:50:18.608Z",
"platformPostId": "instagram_ra3fe2hksfbm",
"permalink": "http://localhost:4300/p/instagram_ra3fe2hksfbm",
"failureReason": null,
"connected": true,
"assets": [
{
"assetId": "01M2SQPAPH4YF66ZKK5Y5XA9Y7",
"url": "http://localhost:4000/v1/assets/01M2SQPAPH4YF66ZKK5Y5XA9Y7/download?exp=1789721422&sig=7266489ee4dd263afb6bbfd652f51c0f1df5c46a4efc5d23bdf28aad752d178e",
"expiresAt": "2026-09-18T08:50:22.000Z"
}
],
"clipStored": true
}
],
"total": 7,
"all": 7,
"page": 1,
"perPage": 1,
"facets": { "state": ["PUBLISHED"], "platform": ["instagram", "tiktok", "youtube"] }
}
postGroupId is the job id. platformPostId is what the platform called the post, which is what a
later takedown needs. Asset URLs are signed and expire; expiresAt says when, so mint a fresh one
rather than storing the URL.
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 job while it runs
GET /v1/pipeline is the live view: what is rendering, what is publishing, and what finished
recently. It tells you how often to ask.
curl "http://localhost:4000/v1/pipeline?limit=1" \
-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": "http://localhost:4300/p/instagram_ra3fe2hksfbm",
"error": null
}
]
}
]
}
}
pollMs is the interval the server suggests. Honour it rather than picking your own.
GET /v1/pipeline/{id} is the same for one job. 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 post that never reached
its platform, 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=1789721412&sig=b33c5f95b1e4d72f68909fc8a9191f9e8cea3ba2be2f89c19739eccf5a96c5b2",
"expiresAt": "2026-09-18T08:50:12.000Z"
}
]
}
]
}
],
"total": 1,
"all": 1,
"page": 1,
"perPage": 3,
"facets": {}
}
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 approval releases the post immediately rather than at the next tick. 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 job
Cancel
POST /v1/jobs/{id}/cancel stops everything still to happen: a render that has not finished, a
post that has not gone out. It cannot reach into the past.
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 row's deletable and repostable flags are how you would have known in
advance.
Update a published post
POST /v1/jobs/{id}/update changes the text of a job's published posts, and optionally the video.
It queues a signed post.update delivery to your connector; the platform is changed by the
connector, not by Reelwire.
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.",
"hashtags": ["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. | 60 tags, 60 characters each |
title | Where the platform has a distinct title field. | 400 characters |
disclaimer | The brand's own line. | 300 characters |
link | { "url": "...", "label": "..." }, or an empty url for no link. Absent keeps the link the post has. | |
mediaFileId | A media library file to post instead of the current video. Absent keeps the video. |
Text over a limit is refused, never cut: a caption shortened on the way out is one nobody wrote.
The fields are the post as it will read, and nothing of the brand's is added to them, so sending
hashtags without one somebody removed keeps it removed.
Repost and delete
POST /v1/jobs/{id}/repost publishes a finished post again, as a new post on the same channel.
POST /v1/jobs/{id}/delete removes the job's published posts from their platforms, through a signed
post.delete delivery. It is not a soft hide, and there is nothing to bring back. A delete that
fails every attempt leaves the post where it was and records why on failureReason.
Each action answers with what it did, and a 409 with a sentence where the job is not in a state that accepts it:
| Action | On success |
|---|---|
cancel | { "ok": true, "cancelledPosts": 2, "renderStopped": true } |
repost | { "ok": true, "postGroupId": "01M..." } (the id of the new job) |
update | { "ok": true, "queued": 1 } |
delete | { "ok": true, "queued": 1 } |
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.
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
Inside are the video and a JSON of the post as Reelwire holds it: the text, where it went, where it
came from and how it was drawn. Pass ?tz=Europe/Berlin to have the times in the file named and
written in your own zone.
Where there is no video, the archive carries a text file whose name is the reason and whose contents explain it, rather than arriving silently without one. A clip removed to make storage room is the common case.
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 job is one post. One event becomes one job per channel, and a job's posts can be in different states: one published, one waiting for a reviewer.
Calling an action and handling the 409 as an error path. updatable, repostable and
deletable on the row say in advance.
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.