Webhooks
Reelwire makes the video. When a post is due it is handed over, by email, by webhook or both, as each of its channels says, and whoever receives it posts it: your own script, an automation, a posting service. (A channel whose platform connector is on is also posted by Reelwire itself: see Channels.) This page is the webhook half: one signed call to an address you set, carrying the video, its cover picture and the text for every channel of the hand-over, and the call you make back once a channel's post is up. Changes and takedowns come to the same address. One address serves the whole workspace: you run one receiver, not one per account.
The Guides chapter walks through the three usual receivers, from the first request to the report back: Handed over by webhook.
| Method | Path | Permission |
|---|---|---|
GET | /v1/webhook | webhooks:read |
PUT | /v1/webhook | webhooks:write |
POST | /v1/webhook/secret | signed-in person |
POST | /v1/webhook/secret/reveal | signed-in person |
POST | /v1/webhook/test | webhooks:write |
POST | /v1/posts/{postId}/report | posts:write |
GET | /v1/deliveries | connections:read |
GET | /v1/deliveries/{id} | connections:read |
POST | /v1/deliveries/{id}/redeliver | connections:write |
Where hand-overs go is configuration, and a key may read and set it. Rotating the signing secret
hands out a new secret, and showing it again hands out the current one, so, like minting an API key,
both belong to a signed-in person with the Administration permission: a key is refused whatever it
holds. An AI assistant connected by signing in holds webhooks:write only where the person connecting it
left that switch on: without it, it may read where hand-overs go, and setting the address or sending a test
delivery takes a key made by hand on the API keys screen, or a person. See
An assistant's connection.
Four things changed, and a receiver written for the earlier form needs all four:
- One call carries every channel of a hand-over, in
channels, each with its ownpostId,mediaandtext. The earlier call carried one channel, withchannel,media,textandcontextat the top level. - Your answer is no longer read. It used to carry
platformPostIdandpermalink, and was taken as the post being published. Now a channel's post is published when you say so with the report-back call. - Every event goes to the one address you set. An update and a delete no longer go to the
address with its
/publishswapped for the action. post.recallis gone. It was declared and never sent.
Where hand-overs go
curl http://localhost:4000/v1/webhook \
-H "Authorization: Bearer rw_your_key_here"
{
"id": "01M1VFRWE7P5V056GE36CXEK5D",
"url": "https://hooks.example.com/reelwire",
"enabled": true,
"secretHint": "whse...kSf8",
"signatureHeader": "x-reelwire-signature",
"signatureFormat": "t=<unix>,v1=<hex hmac-sha256 of `${t}.${rawBody}`>"
}
PUT /v1/webhook sets the address, with { "url": "https://..." }, and answers with the id and
the url as stored. A key needs webhooks:write; a signed-in person needs the Administration
permission, as for Test delivery and Deliver again. It must be HTTPS, carry no user name or
password, be at most 2048 characters with no spaces or line breaks, and resolve to a public address.
That last one is checked against what the host resolves to before every delivery, not only when it
is saved.
POST /v1/webhook/secret, with a session token, rotates the signing secret and returns it
once as secret, exactly as an API key is returned once. Nothing
is notified: the receiver is a third party and has to be given the value, which is the step a real
integration performs and the one a pushed secret would hide. Send { "secret": "..." } to set a
value you already hold, 24 to 200 characters, or send {} to have one generated.
POST /v1/webhook/secret/reveal, with a session token and { "password": "..." } (the password the
person signs in with), returns the current secret again as secret, as an API key's reveal does. It
needs the Administration permission, is held to the sign-in's rate limit, answers 403 for a wrong
password, and is written to the audit trail. No API key can call it. On the Webhook screen these two
are Copy the secret and Rotate the secret, side by side.
Test delivery
POST /v1/webhook/test sends one signed ping to the address now, while you wait, and answers
with what came back:
curl -X POST http://localhost:4000/v1/webhook/test \
-H "Authorization: Bearer rw_your_key_here"
{
"ok": true,
"deliveryId": "01M3DF0R6K9W2E5T8Y1B4N7M3Q",
"url": "https://hooks.example.com/reelwire",
"status": 200,
"durationMs": 184,
"response": "{\"received\":true}",
"error": null
}
It arrives exactly as a hand-over does, with the same three headers, signed the same way and under the same rule about public addresses, so it is the way to check your signature and replay handling before a real post reaches you. It is sent once, never retried, and waits 15 seconds for an answer.
When it does not get through, the answer is still a 200, with ok false and error saying why:
"The receiver answered 401: ..." where your receiver answered, or what went wrong where nothing
did. With no address set yet, it is a 409. Every test is written to the delivery log as a line of
its own, about no post. On the Webhook screen it is the Test delivery button beside the
address, and for an AI assistant it is reelwire_test_webhook, which needs a key holding
webhooks:write (an assistant's sign-in connection holds it only with that switch on, see above).
Which channels come here
Only channels with Handed over by webhook switched on (deliverByWebhook). A channel with Email
only never reaches your address: its hand-overs are emails to the people who publish for the
workspace (see Posts handed over by email). A
channel with both switched on is handed over both ways at once, and the email and your call share
one handoverId. A channel with every way off reaches nobody of the workspace's own.
A channel with Handed over by platform connector switched on as well is also posted by Reelwire itself, and nothing in your call says so: a receiver that posts every channel it is given would post that one a second time.
A channel's format decides nothing about how it is handed over. It decides the shape of the clip and the text written for it. See How a post is handed over.
One call per hand-over
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 still gets
its own render, in its own shape and language. postKey names the post and is the same on every
hand-over of it. A repost is a post of its own, with a postKey of its own.
A post is handed over in batches, not channel by channel:
- The channels that need no approval go together, in one call, once every one of them is rendered and due. One still rendering holds the others back for at most 30 minutes; then the others go, and it follows on its own, in a call of its own.
- A channel that needs approval goes on its own, once it is approved. It never holds the others back, and they never wait for it.
- A channel that is not coming now is left out and named. Its render failed, it was cancelled or
rejected, or its channel is paused or removed.
omittedlists each channel of the post that a call leaves out, with the reason in words, andfollowssays whether it will still come on its own: it waits for approval, it is still rendering, it is not due yet, or its channel is paused and it follows once the channel is resumed. - Nothing is handed over before it is due.
So a post for four channels, one of which asks for approval, arrives as two calls: three channels
together, and the fourth once somebody has approved it. Both carry the same postKey.
One address for every event
event | When it comes |
|---|---|
post.publish | A post is due: the channels of one batch, to post. |
post.update | Somebody changed a post that was handed over: its text, and perhaps its video. |
post.delete | Somebody asked for a post that was handed over to be taken down. |
ping | A test delivery. It is about no post. |
Every one goes to the one address you set. x-reelwire-event and the body's event tell them
apart, so route on those, never on the path.
An update and a delete go the ways the post's channel has switched on now (email, webhook, platform
connector), or the ones of them the request names in ways, and never a way that is off: naming one
is a 409. See Update a post that was handed over
and Delete.
A post.publish can also come again for a post you already had: Resend post request on the
Posts screen with Webhook chosen, POST /v1/jobs/{id}/resend-webhook, sends it built as the post
reads now, with a new deliveryId and handoverId, the same postKey and postIds and an empty
omitted, so a receiver that keeps the delivery ids it processed acts on it again. A post that
failed and is tried again, POST /v1/jobs/{id}/retry (Resend post request on a failed post), comes
the same way: a fresh post.publish with new ids, as the post reads now. So does a post that was deleted and is
sent again the same way, after the post.delete you had for it, which is a new post to put up. See
Trying a failed post again.
Headers on every delivery
| Header | Value |
|---|---|
content-type | application/json; charset=utf-8 |
x-reelwire-signature | t=<unix seconds>,v1=<hex hmac-sha256> |
x-reelwire-delivery | The delivery id, a ULID. The same on every retry of one delivery. |
x-reelwire-event | post.publish, post.update, post.delete or ping |
The charset is declared explicitly because a receiver that infers it can decode UTF-8 as latin1, which turns an em dash in a caption into mojibake and, worse, changes the bytes the signature is checked against.
Verifying the signature
The signed string is `${t}.${rawBody}`, HMAC-SHA256 with your secret, hex encoded. This is the
Stripe construction, and for the same reason: binding the timestamp into the signed string is what
makes a skew check meaningful. Without it, a captured body stays valid forever.
Your signing secret is all you need: every workspace has its own, so a delivery that verifies with
yours is from your workspace and nobody else's. Read t and v1 from the header.
import { createHmac, timingSafeEqual } from "node:crypto";
// `raw` must be the exact bytes of the request body. Not a re-serialised object:
// JSON.stringify of a parsed body will not reproduce them.
export function verify(raw, header, secret) {
const parts = new Map(
header.split(",").map((chunk) => {
const at = chunk.indexOf("=");
return [chunk.slice(0, at).trim(), chunk.slice(at + 1).trim()];
}),
);
const t = Number(parts.get("t"));
const v1 = parts.get("v1");
// 300 seconds. Required, not optional: the signature alone does not stop a replay.
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1 ?? "", "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
Two checks, both required. Reject a timestamp more than 300 seconds away from your own clock, and
reject an x-reelwire-delivery you have already processed. The signature alone does not stop a
replay.
The body is canonicalised before it is signed: object keys sorted, undefined dropped, so the bytes
you receive are exactly the bytes that were signed. Every attempt is signed afresh: a retry carries
the same delivery id, with a new timestamp, the next attempt and its media links signed again.
What a publish carries
A post.publish for a post whose Instagram Reels channel needs no approval, while its TikTok
channel waits for a reviewer:
{
"event": "post.publish",
"deliveryId": "01M3DE9BPKN1YNWG20WCN0MN0V",
"handoverId": "01M3DE9BPJR3NG04N6EKGRYBP3",
"postKey": "01M3DE6QP2QJEC5SSAFKS26RVN",
"occurredAt": "2026-09-25T23:26:55.187Z",
"attempt": 1,
"source": {
"kind": "queue",
"queueName": "Default post list",
"title": "Weekly market wrap"
},
"channels": [
{
"postId": "01M3DE97XZMTT6GX71JAS8Z9VJ",
"postGroupId": "01M3DE6QPKHYJYTX24PFCS8QD4",
"channelId": "01M3DE675G8Y9JPZCHN6VTKMHN",
"channelName": "My brand Reels DE",
"platform": "instagram",
"surface": "instagram-reels",
"ratio": "9x16",
"locale": "de",
"posting": {
"allowComments": true,
"brandedContent": false,
"allowDuetStitch": false,
"hashtagsAsFirstComment": false,
"silent": false,
"notifySubscribers": true,
"shareToFeed": true,
"protectContent": false,
"linksFacebook": false,
"linksInstagram": false,
"linksYoutube": true,
"postAs": "video"
},
"brandName": "My brand",
"template": { "id": "tpl_dbadb7ef4a70", "version": "1.0.0" },
"approval": "none",
"media": [
{
"assetId": "01M3DE97W8E2Q36QC3B9DFZ0DF",
"kind": "video",
"ratio": "9x16",
"mimeType": "video/mp4",
"width": 1080,
"height": 1920,
"durationMs": 15000,
"byteSize": 6235839,
"sha256": "85c4152676d1b376c34624250a681904725d1d73c3bd7fa2f63dc79138ff8b53",
"url": "https://app.reelwire.io/v1/assets/01M3DE97W8E2Q36QC3B9DFZ0DF/download?exp=1790983615&sig=...",
"expiresAt": "2026-10-02T23:26:55.000Z"
},
{
"assetId": "01M3DE97X0XT1B6CP9K7R1CVXW",
"kind": "still",
"ratio": "9x16",
"mimeType": "image/jpeg",
"width": 540,
"height": 960,
"durationMs": 0,
"byteSize": 33524,
"sha256": "2a6735750c0e3f76f658baa2277a3b13e40c76afe12bbcdee6f5bb980b9751b6",
"url": "https://app.reelwire.io/v1/assets/01M3DE97X0XT1B6CP9K7R1CVXW/download?exp=1790983615&sig=...",
"expiresAt": "2026-10-02T23:26:55.000Z"
}
],
"text": {
"locale": "de",
"caption": "Die Märkte schließen die Woche höher. Was das für die kommenden Tage heißt, zeigt der Clip.",
"hashtags": ["beispiel", "reelwire", "mybrand"],
"disclaimer": "My brand shares short video updates on the latest news."
}
}
],
"omitted": [
{
"postGroupId": "01M3DE6QPN8V2KQ4T7X1ZC5RWA",
"channelId": "01M3DE675J3W9E6K1F0PS2BHTD",
"channelName": "My brand TikTok DE",
"reason": "It waits for approval, and follows on its own once approved.",
"follows": true
}
]
}

The keys arrive sorted alphabetically, because the body is canonicalised; they are shown here in reading order.
Points worth noticing:
channelsholds every channel of the batch. Loop over it: a post for three channels without a reviewer is one call with three entries.postIdis what you report back against;postGroupIdis the channel's post on the Posts screen, andchannelIdthe channel.- There is no account handle.
channelIdnames the destination; the receiver maps it onto its own account, which is the only mapping that can be right. Reelwire never held your account names, and a field it could only fill with a guess is worse than no field.channelNameis the name the workspace gave the channel, a label for a person reading a log. platformandsurfaceare the channel's format. They say which shape and which text limits the clip was made for. They promise nothing about who posts it or how.platformis one ofyoutube,facebook,instagram,tiktok,linkedin,telegram,discord,slack,x,pinterest,threadsordownload; the five aftertelegramjoined with the platform connectors, so a receiver that checks the value against a list should take them too.media[0]is the video andmedia[1]its cover still, a JPEG in the same shape, where the renderer made one.- A LinkedIn channel set to post pictures carries pictures instead of a video (
posting.postAspicture,picturesorslides):mediathen holds one item ofkindpictureper picture, a JPEG at the clip's full size numbered bypositionfrom 0 in the order they are posted, the brand's outro end card last; for Slides, and for Pictures alike, one item ofkinddocumentas well, the same pictures as a PDF, a page each, with thetitleLinkedIn shows over a document post; and the coverstilllast. Post the pictures side by side forpictureandpictures, the document forslides. A channel posting a video never carries apicture, so a receiver that only knows videos keeps its LinkedIn channels on Video. A channel switched afterwards hands over what was made before: go by the files rather than bypostAswhere the two differ. The LinkedIn guide says which pages a template gives. sha256is checked after download, not treated as a hint. A mismatch is a hard failure.media[].urlis signed again on every send and works for seven days from it, so a receiver that queues a post for a posting time of its own still finds the file. A clip removed from the workspace's storage in the meantime, to make room for newer ones, answers 404 whatever the link says, so download it soon.textis already localised and fitted to the channel's format. The receiver does not translate.disclaimeris the brand's own text, kept separate from the caption so you can place it after the post's own words.titlecomes where the platform has a title field, andlinkUrlandlinkLabelcarry the brand's link where the format can deliver one.captionscomes only for a channel on No specific platform (platformdownload), which does not name where its clips go. It then holds the caption in every length the post was written in, up to four, one per kind of platform, each with itslength, alabelnaming it and the platforms it is written for, and its owntext:tinyfor Pinterest, Threads and X,shortfor Telegram,mediumfor Discord, Facebook Reels, Instagram, LinkedIn, Slack and TikTok,longfor Facebook Feed and YouTube. It is left out where the post has one caption, or where every length would read the same.tinyis new since release 0.76.0, so a receiver that checkslengthagainst a list should take it too. A channel that names its platform, as the one above does, gets that platform's text intextand nocaptions.- A length can come twice in
captions, where the channel'slinksFacebook,linksInstagramorlinksYoutubesays one platform's account may post no links and another's may:longonce for YouTube and once for Facebook Feed, say. Each entry'ssurfaceslists the surfaces it is written for, so choose by that as well as bylength. A post holding no link keeps one text per length. postingis the channel's Posting settings, with defaults filled in. Every switch is there; the ones its format does not have are always at their defaults and mean nothing for it, andvisibilitycomes only where the format offers a choice and one was made (absent is public). See Posting settings for which format has which. They are yours to apply as you post it; a channel's platform connector applies the ones its platform lets it, and the post kit lists them for whoever posts by hand. Apost.updatecarries them too, as they are when the change is sent.notifySubscribers(YouTube, on unless set off),shareToFeed(Instagram Reels, on unless set off) andprotectContent(Telegram: nobody can forward the post or save the video) are new since release 0.77.0. Since release 0.428.0,allowCommentson YouTube, Facebook, LinkedIn, Threads and Pinterest,brandedContenton YouTube and Facebook, andvisibilityon LinkedIn are no longer offered, so they always read as their defaults there, whatever a channel set before.linksFacebook,linksInstagramandlinksYoutubesay whether the account may post links, and leave you nothing to apply: where the one for the channel's platform is off,textalready carries nolinkUrl, and an address in its caption or disclaimer is written likeexample . com.templateis the exact build that was rendered, never an alias, withvariant, the cut the clip was drawn in (the channel's, or a post-list post's own), where there is one.approvalisnoneorper_post_approved, withapprovedByandapprovedAtwhere a reviewer cleared it.sourcesays where the content came from:broadcast, a feed Reelwire carries, withsourceSlugandformatSlug;stream, one of your custom feeds, withstreamId; orqueue, a post list, withqueueNameand the post's owntitle.handoverIdis shared by the email and the call of one hand-over.attemptstarts at 1 and counts the sends of this delivery.syndicationis never in a call to your own webhook. It is on a call to one of your syndication participants' webhooks,{ "partnerId", "from" }, which carries the same document cut to the channels they chose, signed with their group's secret, and on a testpingtheir group's page sends to a webhook they already have.
An update, a delete and a ping
post.update
{
"event": "post.update",
"deliveryId": "01M3ECC1CXB2N6R0W3Y7C1E5K9",
"handoverId": "01M3ECC1CWXQ2M5P9S3V7Z1D4F",
"postKey": "01M3DE6QP2QJEC5SSAFKS26RVN",
"occurredAt": "2026-09-26T08:12:40.221Z",
"attempt": 1,
"channels": [
{
"postId": "01M3DE97XZMTT6GX71JAS8Z9VJ",
"postGroupId": "01M3DE6QPKHYJYTX24PFCS8QD4",
"channelId": "01M3DE675G8Y9JPZCHN6VTKMHN",
"channelName": "My brand Reels DE",
"platform": "instagram",
"surface": "instagram-reels",
"ratio": "9x16",
"locale": "de",
"platformPostId": "18034567890123456",
"permalink": "https://www.instagram.com/reel/DAb3xYz9QwE/",
"posting": {
"allowComments": true,
"brandedContent": false,
"allowDuetStitch": false,
"hashtagsAsFirstComment": false,
"silent": false,
"notifySubscribers": true,
"shareToFeed": true,
"protectContent": false,
"linksFacebook": false,
"linksInstagram": false,
"linksYoutube": true
},
"text": {
"locale": "de",
"caption": "Die Märkte schließen die Woche höher, angeführt von den Technologiewerten.",
"hashtags": ["beispiel", "reelwire", "mybrand"],
"disclaimer": "My brand shares short video updates on the latest news."
}
}
]
}
text is the whole text as it should now read, not a difference, and captions comes where the
channel is on No specific platform. media is there only when the video was replaced: it then holds
the new video, a file from the workspace's media library, with assetId, kind video,
mimeType, byteSize, sha256, url and expiresAt, signed like a publish's clip. Without it,
keep the video you have. platformPostId and permalink are what was reported back for that
channel, where anybody did, so you can find the post again. posting is the channel's posting
settings as they are now, as a publish carries them.
Reelwire changes nothing where the post is: your receiver does. The post in Reelwire takes the new text once the call has got through.
post.delete
{
"event": "post.delete",
"deliveryId": "01M3H534R4C5F8J1M4Q7T0X3A6",
"handoverId": "01M3H534R3BR9V3Y6B0E4H7K1N",
"postKey": "01M3DE6QP2QJEC5SSAFKS26RVN",
"occurredAt": "2026-09-27T10:03:12.004Z",
"attempt": 1,
"channels": [
{
"postId": "01M3DE97XZMTT6GX71JAS8Z9VJ",
"postGroupId": "01M3DE6QPKHYJYTX24PFCS8QD4",
"channelId": "01M3DE675G8Y9JPZCHN6VTKMHN",
"channelName": "My brand Reels DE",
"platform": "instagram",
"surface": "instagram-reels",
"ratio": "9x16",
"locale": "de",
"platformPostId": "18034567890123456",
"permalink": "https://www.instagram.com/reel/DAb3xYz9QwE/"
}
]
}
Take the post down for good, not a hide, then report deleted. Until somebody does, the channel
reads Delete requested on the Posts screen.
ping
{
"event": "ping",
"deliveryId": "01M3DF0R6K9W2E5T8Y1B4N7M3Q",
"occurredAt": "2026-09-25T23:39:41.702Z",
"attempt": 1,
"message": "reelwire webhook test"
}
What you answer
Any 2xx is a delivery. The body is kept for the delivery log and not read, so this is plenty:
{ "received": true }
A 2xx says you have the hand-over, nothing more. It publishes nothing and marks nothing as published: the channel's post stays handed over on the Posts screen until you report back.
Anything else, a redirect included, is a failed delivery and is tried again. A redirect is never followed to wherever it points. Only public addresses are delivered to. A 410 Gone is a failure like any other here and switches nothing off: that answer only takes a syndication participant's webhook off its group.
Reporting back
Whether a channel's post went out, you say separately, per channel, once you know:
curl http://localhost:4000/v1/posts/01M3DE97XZMTT6GX71JAS8Z9VJ/report \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{"status":"published","url":"https://www.instagram.com/reel/DAb3xYz9QwE/","platformPostId":"18034567890123456"}'
{
"ok": true,
"postId": "01M3DE97XZMTT6GX71JAS8Z9VJ",
"postGroupId": "01M3DE6QPKHYJYTX24PFCS8QD4",
"state": "PUBLISHED",
"permalink": "https://www.instagram.com/reel/DAb3xYz9QwE/"
}
It takes an API key holding Posts write, posts:write. The id is channels[].postId from the
hand-over; a channel's id from GET /v1/jobs works too.
| Field | What it is |
|---|---|
status | Required: published, skipped, failed or deleted. |
url | With published: where the post is, an http or https address of up to 2000 characters. |
platformPostId | With published: the platform's own id for the post, up to 200 characters. |
reason | With failed: why it could not be posted, up to 1000 characters, in words a person reads on the Posts screen. |
status | Accepted while the post is | It becomes |
|---|---|---|
published | PUBLISHING, HANDED_OVER, SKIPPED or FAILED | PUBLISHED, with publishedAt set to now and the link on the Posts screen |
skipped | HANDED_OVER | SKIPPED: handed over and deliberately not posted |
failed | PUBLISHING or HANDED_OVER | FAILED, with the reason on the Posts screen |
deleted | HANDED_OVER or PUBLISHED | RECALLED, which the Posts screen shows as Deleted |
published is accepted while the post is still PUBLISHING because a receiver may post it and
report before it has answered the call that brought it; your answer does not undo the report. A
skipped or failed post can still be reported published later.
Saying the same thing again is fine: nothing moves, and the newest link and id are kept. Anything
the post's state does not allow is a 409 that names the state: "This post is published, so it
cannot be reported skipped." An unknown status, or a link that is not a web address, is a 400,
and an id that is not one of the workspace's posts a 404.
The link and platformPostId you report travel with every later post.update and post.delete
for that channel, so whoever has the post can find it again.
The same report is Mark as published, Skip and Mark as deleted on the
Posts screen and in the post kit from the email, and
reelwire_report_post for an AI assistant. Whoever says it, the rules are these.
Retries
Seven attempts over a little more than three hours, then the delivery is dead:
| Attempt | Wait before it |
|---|---|
| 1 | immediate |
| 2 | 10 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 15 minutes |
| 6 | 1 hour |
| 7 | 2 hours |
Each call has 30 seconds to answer before it counts as failed. When the ladder runs out:
- A publish that got through no way at all makes its channels' posts
FAILED, with the reason on the Posts screen. Where another way is switched on too, the email or the platform connector, and it went or is still on its way, they stay where that one puts them, and the Posts screen says what went wrong with the webhook. - An update or a delete leaves the post exactly as it was and says why, rather than pretending it changed.
An email line climbs the same ladder. A platform connector's line does too, with eight minutes for
each attempt, except that a refusal that would come back every time, such as a wrong token or a
video too large for the platform, stops it at once. So does a publish on Telegram, Discord or Slack
that a 5xx or no answer leaves unclear: the post may be up, nothing there can say so, and another
attempt could post it twice. The line is DEAD and the post HANDED_OVER, with the reason on it,
for somebody to look and report it or resend it.
GET /v1/overview carries attention.deadDeliveries, which is the number to alert on.
Watching and resending
GET /v1/deliveries is the delivery log, newest first: one line per way out of each hand-over, so
a hand-over that went by email and by webhook is two lines. Filter with state, mechanism
(EMAIL, WEBHOOK or CONNECTOR) and event, search with q (a channel's name, the error, the
address a line went to), narrow to a period with since and until, and order with sort
(createdAt, the default, or updatedAt) and ascending. It is paged like every other log: page
from 1 and perPage up to 200, 40 when left out (limit is read the same), and the answer says
total, how many match, and all, how many the period holds.
curl "http://localhost:4000/v1/deliveries?mechanism=WEBHOOK&perPage=1" \
-H "Authorization: Bearer rw_your_key_here"
{
"deliveries": [
{
"id": "01M3DE9BPKN1YNWG20WCN0MN0V",
"event": "post.publish",
"mechanism": "WEBHOOK",
"state": "DELIVERED",
"attempt": 1,
"nextAttemptAt": "2026-09-25T23:26:55.192Z",
"lastStatusCode": 200,
"lastError": null,
"note": null,
"deliveredAt": "2026-09-25T23:26:55.412Z",
"createdAt": "2026-09-25T23:26:55.193Z",
"handoverId": "01M3DE9BPJR3NG04N6EKGRYBP3",
"postId": "01M3DE97XZMTT6GX71JAS8Z9VJ",
"postIds": ["01M3DE97XZMTT6GX71JAS8Z9VJ"],
"channels": ["My brand Reels DE"],
"channelName": "My brand Reels DE",
"platform": "instagram"
}
],
"total": 12,
"all": 31,
"page": 1,
"perPage": 1,
"facets": {
"state": ["DEAD", "DELIVERED"],
"mechanism": ["EMAIL", "WEBHOOK"],
"event": ["ping", "post.publish"]
}
}
facets are the values the period actually holds, so a filter never offers a choice that empties
the list.
state runs PENDING, DELIVERING, RETRYING, DELIVERED, DEAD. postIds are the channels'
posts the line carried and channels their channels' names; postId and platform are filled only
on a line that carried one. mechanism is EMAIL, WEBHOOK, or CONNECTOR for a post a channel's
platform connector made: one line per post, whose response holds the platform's id and the link
to the post. note is what a connector said about a line that went out anyway, such as a LinkedIn
post whose comments it could not close; it is null everywhere else.
GET /v1/deliveries/{id} answers one line whole:
{
"id": "01M3DE9BPKN1YNWG20WCN0MN0V",
"event": "post.publish",
"mechanism": "WEBHOOK",
"state": "DELIVERED",
"attempt": 1,
"nextAttemptAt": "2026-09-25T23:26:55.192Z",
"lastStatusCode": 200,
"lastError": null,
"deliveredAt": "2026-09-25T23:26:55.412Z",
"createdAt": "2026-09-25T23:26:55.193Z",
"postIds": ["01M3DE97XZMTT6GX71JAS8Z9VJ"],
"channels": ["My brand Reels DE"],
"to": "https://hooks.example.com/reelwire",
"handover": {
"id": "01M3DE9BPJR3NG04N6EKGRYBP3",
"postKey": "01M3DE6QP2QJEC5SSAFKS26RVN",
"event": "post.publish",
"postIds": ["01M3DE97XZMTT6GX71JAS8Z9VJ"],
"omitted": [
{
"postGroupId": "01M3DE6QPN8V2KQ4T7X1ZC5RWA",
"channelId": "01M3DE675J3W9E6K1F0PS2BHTD",
"channelName": "My brand TikTok DE",
"reason": "It waits for approval, and follows on its own once approved.",
"follows": true
}
],
"resend": false,
"createdAt": "2026-09-25T23:26:55.191Z"
},
"response": { "received": true }
}
It also carries payload, left out above: the body as it last went out, with the links it was
signed with that time. to is the address the call went to, kept on the line because the workspace
may point its webhook elsewhere later; for an email it says who the email goes to, and for a
connector line what the platform connector posted as. resend is true for a hand-over somebody
asked for again, such as Resend post request on the Posts screen. response is what came back: the
receiver's JSON, or raw with the start of its words.
POST /v1/deliveries/{id}/redeliver sends one line again, delivered or not: a webhook call with the
same delivery id and its links signed again, an email sent again as it was. The attempt count starts
again. A publish whose every way had died made its channels' posts FAILED; sending it again takes
them back to being handed over, so the line's success lands on them. A key needs
connections:write; a signed-in person needs the Administration permission. A connector line that
got through is a 409: a platform does not know a delivery id, and would take the post a second
time. On the Activity screen it is Deliver again on a line of What went out.
{ "id": "01M3DE9BPKN1YNWG20WCN0MN0V", "state": "PENDING" }
The usual reason to send a delivered line again is that the receiver lost the post rather than that Reelwire failed to send it. A receiver that records a delivery id only once it has processed it takes the redelivery when it lost the post, and ignores it when it did not.
A line stuck in DELIVERING means the process died after claiming it. Those are released back
automatically; you do not need to redeliver them.
An AI assistant reads the same log with reelwire_list_deliveries (one line whole with its
deliveryId), and sends a line again with reelwire_redeliver.
Building a receiver
You need not start from nothing. The Get your code card on the Webhook screen is a working
receiver to copy, in Node.js, Python, PHP or C#: it checks the signature and the timestamp as below,
skips a delivery id it has handled, and for each channel of a post.publish reports the post back
with POST /v1/posts/{postId}/report to the Reelwire it came from. Two placeholders stand in for your
secrets, YOUR_SIGNING_SECRET (Copy the secret shows it) and YOUR_API_KEY, a key with Posts:
write; the part that posts the video where it belongs is yours to write. See
Get your code.
Whatever it is written in, a receiver does these things, in this order:
- Read the raw body. Verify the signature on those bytes before parsing.
- Check the timestamp is inside your skew window.
- Check
x-reelwire-deliveryagainst the ids you have already processed, and answer 200 for a repeat without doing the work twice. Record an id once its work is done, not before. - Answer 2xx, then do the work: accept, queue and answer, rather than posting while Reelwire waits.
- For a
post.publish, for each entry inchannels: mapchannelIdonto your own account, downloadmedia[0].urland verify itssha256, post it withtext, or with the length fromcaptionsthat suits where it goes, and then report back with itspostId:publishedwith the link, orfailedwith the reason. - For a
post.update, change the post you made for each channel, found by itspostId,platformPostIdorpermalink, with the new text and, wheremediais there, the new video. For apost.delete, take it down and reportdeleted. - For a
ping, answer 2xx and nothing else.
Thirty seconds is the limit for the answer, and a slow answer looks exactly like a failed one.
Mistakes people make
Verifying against a re-serialised body. JSON.stringify(req.body) does not reproduce the bytes
that were signed. Keep the raw body.
Skipping the timestamp check, or the delivery id check. Both are needed. The signature alone does not stop a replay.
Comparing signatures with ===. Use a constant-time comparison.
Reading only the first channel. A hand-over carries every channel of its batch in channels.
Taking your 2xx for the report. Reelwire marks nothing published on its own. Until you report back, the post reads handed over on the Posts screen.
Reporting against the wrong id. Report with channels[].postId, one call per channel. postKey
names the whole post, and handoverId one hand-over of it.
Waiting for a channel that is not coming. Read omitted: a channel with follows false will
not be handed over for this post.
Storing media[].url for later. The link works for seven days from the send, and a clip removed
to make room answers 404 before then. Download the file while you handle the call.
Posting inline and answering late. A slow receiver looks like a failed one after 30 seconds, and then you get the same hand-over again.
Answering 3xx. A redirect is a failed delivery, never a second request.
Routing on the URL path. Every event comes to the one address. Route on x-reelwire-event.