Skip to main content

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.

MethodPathPermission
GET/v1/webhookwebhooks:read
PUT/v1/webhookwebhooks:write
POST/v1/webhook/secretsigned-in person
POST/v1/webhook/testwebhooks:write
POST/v1/posts/{postId}/reportposts:write
GET/v1/deliveriesconnections:read
GET/v1/deliveries/{id}connections:read
POST/v1/deliveries/{id}/redeliverconnections: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.

Built a receiver for the earlier webhook?

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 own postId, media and text. The earlier call carried one channel, with channel, media, text and context at the top level.
  • Your answer is no longer read. It used to carry platformPostId and permalink, 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 /publish swapped for the action.
  • post.recall is 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. omitted lists each channel of the post that a call leaves out, with the reason in words, and follows says 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​

eventWhen it comes
post.publishA post is due: the channels of one batch, to post.
post.updateSomebody changed a post that was handed over: its text, and perhaps its video.
post.deleteSomebody asked for a post that was handed over to be taken down.
pingA 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​

HeaderValue
content-typeapplication/json; charset=utf-8
x-reelwire-signaturet=<unix seconds>,v1=<hex hmac-sha256>
x-reelwire-deliveryThe delivery id, a ULID. The same on every retry of one delivery.
x-reelwire-eventpost.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:

  • channels holds every channel of the batch. Loop over it: a post for three channels without a reviewer is one call with three entries. postId is what you report back against; postGroupId is the channel's post on the Posts screen, and channelId the channel.
  • There is no account handle. channelId names 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. channelName is the name the workspace gave the channel, a label for a person reading a log.
  • platform and surface are 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 and media[1] its cover still, a JPEG in the same shape, where the renderer made one.
  • sha256 is checked after download, not treated as a hint. A mismatch is a hard failure.
  • media[].url is 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.
  • text is already localised and fitted to the channel's format. The receiver does not translate. disclaimer is the brand's own text, kept separate from the caption so you can place it after the post's own words. title comes where the platform has a title field, and linkUrl and linkLabel carry the brand's link where the format can deliver one.
  • captions comes only for a channel on No specific platform (platform download), which does not name where its clips go. It then holds the caption in three lengths, one per kind of platform, each with its length, a label naming the platforms it is written for, and its own text: short for Telegram, medium for Instagram, TikTok, Facebook Reels and LinkedIn, long for YouTube and Facebook Feed. A channel that names its platform, as the one above does, gets that platform's text in text and no captions.
  • posting is the channel's posting switches, with defaults filled in, and visibility where the format has one.
  • template is the exact build that was rendered, never an alias, with variant where the channel chose one.
  • approval is none or per_post_approved, with approvedBy and approvedAt where a reviewer cleared it.
  • source says where the content came from: broadcast, a feed Reelwire carries, with sourceSlug and formatSlug; stream, one of your custom feeds, with streamId; or queue, a post list, with queueName and the post's own title.
  • handoverId is shared by the letter and the call of one hand-over. attempt starts at 1 and counts the sends of this delivery.
  • syndication is 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.

FieldWhat it is
statusRequired: published, skipped, failed or deleted.
urlWith published: where the post is, an http or https address of up to 2000 characters.
platformPostIdWith published: the platform's own id for the post, up to 200 characters.
reasonWith failed: why it could not be posted, up to 1000 characters, in words a person reads on the Posts screen.
statusAccepted while the post isIt becomes
publishedPUBLISHING, HANDED_OVER, SKIPPED or FAILEDPUBLISHED, with publishedAt set to now and the link on the Posts screen
skippedHANDED_OVERSKIPPED: handed over and deliberately not posted
failedPUBLISHING or HANDED_OVERFAILED, with the reason on the Posts screen
deletedHANDED_OVER or PUBLISHEDRECALLED, 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:

AttemptWait before it
1immediate
210 seconds
31 minute
45 minutes
515 minutes
61 hour
72 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:

  1. Read the raw body. Verify the signature on those bytes before parsing.
  2. Check the timestamp is inside your skew window.
  3. Check x-reelwire-delivery against 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.
  4. Answer 2xx, then do the work: accept, queue and answer, rather than posting while Reelwire waits.
  5. For a post.publish, for each entry in channels: map channelId onto your own account, download media[0].url and verify its sha256, post it with text, or with the length from captions that suits where it goes, and then report back with its postId: published with the link, or failed with the reason.
  6. For a post.update, change the post you made for each channel, found by its postId, platformPostId or permalink, with the new text and, where media is there, the new video. For a post.delete, take it down and report deleted.
  7. 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.