Webhooks
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 of its channels says, and whoever receives it posts it: your own script, an automation, a posting service. 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/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, so, like minting an API key, it belongs to a signed-in person: a key is refused whatever it holds.
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. 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.
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 channels come here
Only channels with Webhook switched on. A channel with Email only never reaches your address:
its hand-overs are letters 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 letter and your call share one handoverId.
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, 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 or both, and never a way that is off.
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
},
"brandName": "My brand",
"template": { "id": "tpl_0083ab80ce80", "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.media[0]is the video andmedia[1]its cover still, a JPEG in the same shape, where the renderer made one.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 three lengths, one per kind of platform, each with itslength, alabelnaming the platforms it is written for, and its owntext:shortfor Telegram,mediumfor Instagram, TikTok, Facebook Reels and LinkedIn,longfor YouTube and Facebook Feed. A channel that names its platform, as the one above does, gets that platform's text intextand nocaptions.postingis the channel's posting switches, with defaults filled in, andvisibilitywhere the format has one.templateis the exact build that was rendered, never an alias, withvariantwhere the channel chose 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 letter 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 subscribers' webhooks,{ "partnerId", "from" }, which carries the same document cut to their feeds, signed with their group's secret.
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/",
"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.
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.
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 Email is switched on too and the letter went, they stay handed over, 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.
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 or WEBHOOK) 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,
"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.
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 a letter goes to. resend is true
for an email sent again with Resend email. 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 as the same letter. 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.
{ "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 and reelwire_delivery, and sends
a line again with reelwire_redeliver.
Building a receiver
It 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.