Webhooks
Reelwire does not post to platforms itself. When a post is ready it sends your endpoint a signed delivery carrying the video, the text and where it goes, and your connector publishes it. The same channel carries takedowns and edits. One endpoint serves the whole workspace: a customer runs one connector, not one per account.
| Method | Path | Permission |
|---|---|---|
GET | /v1/webhook | webhooks:read |
PUT | /v1/webhook | webhooks:write |
POST | /v1/webhook/secret | webhooks:write |
GET | /v1/deliveries | channels:read |
POST | /v1/deliveries/{id}/redeliver | channels:write |
The three /v1/webhook routes currently require a signed-in person, not an API key. A key
holding webhooks:write is refused with "Sign in to manage credentials. An API key cannot manage
API keys." Set the endpoint and rotate the secret from the dashboard, or with a session token.
Where deliveries go
curl http://localhost:4000/v1/webhook \
-H "Authorization: Bearer rws_your_session_token"
{
"id": "01M1VFRWE7P5V056GE36CXEK5D",
"url": "http://connector-simulator:4300/v1/webhooks/publish/enterprise-2",
"enabled": true,
"keyId": "01M1VFRWE7P5V056GE36CXEK5D",
"secretHint": "whse...kSf8",
"signatureHeader": "x-reelwire-signature",
"signatureFormat": "t=<unix>,kid=<keyId>,v1=<hex hmac-sha256 of `${t}.${rawBody}`>"
}
PUT /v1/webhook sets the URL, with { "url": "https://..." }. It must be HTTPS and must resolve
to a public address; that is checked against what the host resolves to at delivery time, not only at
save time.
POST /v1/webhook/secret rotates the signing secret and returns it once, 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, at least the minimum length, or send {} to
have one generated.
The one URL becomes three
An update and a delete go to the publish URL with its /publish path segment swapped for the
action:
| Event | URL |
|---|---|
post.publish | https://hooks.example.com/webhooks/publish/acme |
post.update | https://hooks.example.com/webhooks/update/acme |
post.delete | https://hooks.example.com/webhooks/delete/acme |
A URL with no /publish segment is used as it is, and the event header tells them apart. Route on
the header rather than the path and both layouts work.
Headers on every delivery
| Header | Value |
|---|---|
content-type | application/json; charset=utf-8 |
x-reelwire-signature | t=<unix seconds>,kid=<keyId>,v1=<hex hmac-sha256> |
x-reelwire-delivery | The delivery id, a ULID |
x-reelwire-event | post.publish, post.recall, 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.
kid says which secret signed this. It sits outside the HMAC deliberately: it is a selector, not
a claim. A receiver looks it up, and an id it does not know is a clean rejection rather than a
signature mismatch that looks like a bug in the sender. Every workspace has its own secret, so one
receiver holding a shared secret cannot forge a delivery for another customer.
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, secretsByKeyId) {
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 kid = parts.get("kid");
const v1 = parts.get("v1");
const secret = secretsByKeyId[kid];
if (!secret) return false;
// 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 outside your skew window, 300 seconds being the value
Reelwire is built around, and reject a 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. That is what makes a redelivery byte-identical to the original, and therefore identically signed.
What a publish carries
The body below is the shape of a post.publish, filled in with the ids from the worked example on
Sending events.
{
"event": "post.publish",
"deliveryId": "01M...",
"postId": "01M...",
"postGroupId": "01M...",
"occurredAt": "2026-09-18T07:46:30.514Z",
"attempt": 1,
"channel": {
"channelId": "01M2985NX4V0WA1GV9MC63DEPJ",
"platform": "instagram",
"surface": "instagram-reels",
"posting": { "allowComments": true, "brandedContent": false }
},
"media": [
{
"assetId": "01M2SQPAJYJR7YB27466FV30SZ",
"kind": "video",
"ratio": "9x16",
"mimeType": "video/mp4",
"width": 1080,
"height": 1920,
"durationMs": 10000,
"byteSize": 746396,
"sha256": "19c629bfd5a1ec22a1fb9baed2c043ce23d523d01c5886ffd0d4278be444e5c8",
"url": "http://localhost:4000/v1/assets/01M2SQPAJYJR7YB27466FV30SZ/download?exp=...&sig=...",
"expiresAt": "2026-09-18T08:50:12.000Z"
}
],
"text": {
"locale": "de",
"caption": "The ten-year settled at 2.41 per cent.",
"hashtags": ["bunds", "rates"],
"disclaimer": "Orchid Invest. Educational only. Not advice.",
"title": "Bund yields close at a three-week high"
},
"context": {
"channelName": "Channel 4 de",
"brandName": "Brand 1",
"subscription": { "subscriptionId": "01M...", "kind": "stream", "streamId": "st_30472168d941" },
"templateId": "tpl_a9c3fdb1b5c9",
"templateVersion": "1.0.0",
"approval": "none"
}
}
Points worth noticing:
- 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. sha256is checked after download, not treated as a hint. A mismatch is a hard failure.media[].urlexpires. Download it while you are handling the delivery.textis already localised. The receiver does not translate.disclaimeris the brand's own line, kept separate from the caption so you can place it after the post's own words.attemptstarts at 1 and increases with each redelivery of the same post.
What you answer
{ "received": true, "platformPostId": "instagram_f7hp75jnx2m9", "permalink": "https://..." }
received: true is the only required field. platformPostId is what a later takedown or edit needs,
so return it, and Reelwire records it against the post. ping has no post id to return.
Any 2xx is success. Anything else, including a redirect, is a failed delivery: a redirect is never followed to wherever it points.
Retries
Seven attempts over a little more than three hours, then a dead letter:
| 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 delivery has 30 seconds to answer before it counts as failed. When the ladder runs out the
delivery goes to DEAD and the post is marked failed, so it shows up somewhere a person looks. A
failed update or delete leaves the post exactly as it was and records why on
failureReason, rather than pretending it changed.
GET /v1/overview carries attention.deadDeliveries, which is the number to alert on.
Watching and resending
GET /v1/deliveries lists what has been tried, newest first:
{
"deliveries": [
{
"id": "01M2SQPCF8DSFKXF22P648R59X",
"event": "post.publish",
"state": "DELIVERED",
"attempt": 1,
"nextAttemptAt": "2026-09-18T07:46:30.513Z",
"lastStatusCode": 200,
"lastError": null,
"deliveredAt": "2026-09-18T07:46:30.712Z",
"createdAt": "2026-09-18T07:46:30.514Z",
"postId": "01M2SQPANAT1W066PMYNXFTCNZ",
"channelName": "Channel 4 de",
"platform": "instagram"
}
]
}
state runs PENDING, DELIVERING, RETRYING, DELIVERED, DEAD. POST /v1/deliveries/{id}/redeliver resets a dead or retrying one to PENDING and sends it again,
byte-identical, with the attempt counter back at zero:
{ "id": "01M2SQPCF8DSFKXF22P648R59X", "state": "PENDING" }
A row stuck in DELIVERING means the process died after claiming it. Those are released back
automatically; you do not need to redeliver them.
Building a receiver
- 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 return 200 for a repeat without doing the work twice. - Download
media[].url, verifysha256. - Publish, and answer
{ "received": true, "platformPostId": "..." }.
Answer quickly. Thirty seconds is the limit, and the honest shape is to accept, queue and answer rather than publish inline.
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.
Storing media[].url. It expires. Download it now, or ask Reelwire for the asset again later.
Publishing inline and answering late. A slow receiver looks like a failed one after 30 seconds, and then you get the same post again.
Answering 3xx. A redirect is a failed delivery, never a second request.
Not returning platformPostId. Without it, a later takedown or edit has nothing to act on.
Routing on the URL path alone. Route on x-reelwire-event. A URL without a /publish segment
receives all three kinds on one address.