Instead of polling for results, give Reelspan a URL. It sends a signed JSON POST there each time a network's post finishes and each time an account is connected, disconnected or needs reconnecting.
Events
| Event | Sent when |
|---|---|
upload_completed |
One network's post went live or failed. A post to three networks sends three. |
social_account_connected |
An account was connected (or reconnected) to a profile. |
social_account_disconnected |
An account was disconnected from a profile. |
social_account_reauth_required |
A network signed Reelspan out of an account; posts to it fail until it is reconnected. |
Skipped networks (no account connected, for example) don't send upload_completed. A test event, test, is sent only when you ask for one.
Set up an endpoint
Set the account webhook in the dashboard under Settings, or with the API. Your URL must use https:// and be publicly reachable.
curl -X POST https://api.reelspan.com/v1/webhook \
-H "Authorization: Bearer $REELSPAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://example.com/reelspan",
"webhook_events": ["upload_completed", "social_account_reauth_required"]
}'import { Reelspan } from 'reelspan';
const reelspan = new Reelspan();
const { notifications } = await reelspan.webhooks.set('https://example.com/reelspan', ['upload_completed', 'social_account_reauth_required']);
console.log(notifications.webhook_secret); // keep this to check signaturesfrom reelspan import Reelspan
reelspan = Reelspan()
notifications = reelspan.webhooks.set("https://example.com/reelspan", ["upload_completed", "social_account_reauth_required"])["notifications"]
print(notifications["webhook_secret"]) # keep this to check signatures{
"success": true,
"notifications": {
"channels": { "webhook": true },
"webhook_url": "https://example.com/reelspan",
"webhook_events": {
"upload_completed": true,
"social_account_connected": false,
"social_account_disconnected": false,
"social_account_reauth_required": true
},
"webhook_secret": "whsec_…",
"consecutive_failures": 0
}
}- Leave out
webhook_eventsto get all four. It also takes an object of booleans ({ "upload_completed": true }) and dotted names (social_account.connected). An unknown event name gets 400INVALID_WEBHOOK. - Saving again changes the URL or events and keeps the signing secret.
GET /v1/webhookreads the settings, secret included;DELETE /v1/webhook(or"channels": { "webhook": false }) removes the webhook. - The key needs the
webhooksscope, which is off by default when you create a key in the dashboard. The account webhook also needs a key that isn't limited to some profiles.
A webhook per profile
A profile can have its own endpoint, sent in addition to the account one, for that profile's events only. Useful when each client has their own server.
curl -X POST https://api.reelspan.com/v1/profile-webhook \
-H "Authorization: Bearer $REELSPAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"profile_username": "client-a", "webhook_url": "https://client-a.example.com/hooks"}'const { webhook } = await reelspan.webhooks.profile.set('client-a', 'https://client-a.example.com/hooks');webhook = reelspan.webhooks.profile.set("client-a", "https://client-a.example.com/hooks")["webhook"]It has its own webhook_secret, in the answer under webhook. Read it with GET /v1/profile-webhook?profile_username=client-a and remove it with DELETE /v1/profile-webhook and {"profile_username": "client-a"} as the body. A key limited to some profiles can manage the webhooks of those profiles.
Payloads
Every delivery is a JSON body with event, the event's data and created_at.
upload_completed
{
"event": "upload_completed",
"request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1",
"job_id": null,
"external_id": "campaign-42",
"profile_username": "my-brand",
"platform": "tiktok",
"media_type": "video",
"title": "Launch day",
"caption": "Launch day #behindthescenes",
"result": {
"success": true,
"url": "https://www.tiktok.com/@mybrand/video/7441…",
"post_id": "7441…",
"error": null,
"error_code": null
},
"created_at": "2026-12-01T09:00:41.512Z"
}platformis the one network this event is about;media_typeisvideo,photo,textordocument.titleis the post'stitle;captionis the text this network got (its{network}_title, elsetitle).resultsays how it went:urlandpost_idwhen it went live,erroranderror_codewhen it failed. It can carry more keys from the network, such asfirst_comment. A test key post arrives with"success": true,"test": true, apost_idstarting withtest_and nourl.request_id,job_idandexternal_idtie it back to the post you made.
Account events
{
"event": "social_account_reauth_required",
"platform": "instagram",
"account_name": "mybrand",
"status": "reauth_required",
"profile_username": "my-brand",
"reason": "token_refresh_failed",
"created_at": "2026-12-01T09:00:41.512Z"
}status is connected, disconnected or reauth_required. reason is there when Reelspan knows it: manual_disconnect on a disconnect, token_refresh_failed or permission_revoked when reconnecting is needed.
test
{ "event": "test", "message": "Webhook test from Reelspan", "created_at": "2026-12-01T09:00:41.512Z" }Send one with POST /v1/webhook/test (or reelspan.webhooks.test()); the answer has its delivery_id. It goes to the account webhook.
Headers
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Reelspan-Webhooks/1.0 |
X-Reelspan-Event |
The event name. |
X-Reelspan-Delivery |
The delivery's id. It stays the same across retries, so use it to ignore a delivery you've already handled. |
X-Reelspan-Timestamp |
Unix seconds when this attempt was sent. |
X-Reelspan-Signature |
sha256= and the hex HMAC-SHA256 of "{timestamp}.{raw body}", keyed with your signing secret. |
Check the signature
Check every delivery before you trust it: compute the HMAC over the timestamp, a dot and the raw body (before any JSON parsing), compare it with the header in constant time, and reject timestamps more than a few minutes old so an old delivery can't be replayed.
The SDKs do all of it, with a 5-minute window:
import express from 'express';
import { verifyWebhook } from 'reelspan';
const app = express();
app.post('/reelspan', express.raw({ type: 'application/json' }), async (req, res) => {
const ok = await verifyWebhook(
req.body.toString(),
req.get('x-reelspan-signature'),
req.get('x-reelspan-timestamp'),
process.env.REELSPAN_WEBHOOK_SECRET,
);
if (!ok) return res.sendStatus(401);
res.sendStatus(200);
const event = JSON.parse(req.body.toString());
// handle the event after answering
});import os
from flask import Flask, request
from reelspan import verify_webhook
app = Flask(__name__)
@app.post("/reelspan")
def reelspan_hook():
ok = verify_webhook(
request.get_data(),
request.headers.get("X-Reelspan-Signature"),
request.headers.get("X-Reelspan-Timestamp"),
os.environ["REELSPAN_WEBHOOK_SECRET"],
)
if not ok:
return "", 401
event = request.get_json()
# queue the event and answer straight away
return "", 200Without the SDK, it is a few lines in any language:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, signature, timestamp, secret) {
if (!signature || !/^\d+$/.test(timestamp ?? '')) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // older than 5 minutes
const expected = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}import hashlib
import hmac
import time
def verify(raw_body: bytes, signature: str | None, timestamp: str | None, secret: str) -> bool:
if not signature or not timestamp or not timestamp.isdigit():
return False
if abs(time.time() - int(timestamp)) > 300: # older than 5 minutes
return False
signed = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Sign the body exactly as it arrived. A framework that parses JSON and you then re-serialise it will change the bytes and the check will fail.
Answer quickly
Answer with any 2xx within 10 seconds. Anything else counts as a failed attempt: another status, a timeout, a connection error, or a redirect (redirects aren't followed). Do slow work after you answer, and make your handler safe to run twice for the same X-Reelspan-Delivery.
Retries
A failed delivery is tried again, up to 8 attempts in all, waiting longer each time:
| After attempt | Next attempt in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 12 hours |
| 7 | 24 hours |
Each attempt has a fresh timestamp and signature. Retries mean deliveries can arrive out of order, so use created_at and the post's status, not arrival order.
If something keeps failing, the workspace gets a dashboard notification (at most one a day per endpoint). After 20 deliveries in a row have failed every attempt, the endpoint is paused (GET /v1/webhook then shows "channels": { "webhook": false }) and you get a notification and an email. Fix it, then save the webhook again (POST /v1/webhook or the dashboard) to turn it back on.
Delivery log
GET /v1/webhook/deliveries lists the workspace's recent deliveries, newest first, for the account and every profile webhook. limit is 1 to 200 (default 50).
const { deliveries } = await reelspan.webhooks.deliveries();
for (const d of deliveries.filter((d) => d.status === 'failed')) await reelspan.webhooks.redeliver(d.id);for d in reelspan.webhooks.deliveries()["deliveries"]:
if d["status"] == "failed":
reelspan.webhooks.redeliver(d["id"]){
"success": true,
"deliveries": [
{
"id": "dlv_Wm4Kq8Zt1Rb6Yn3Px9Cv",
"event": "upload_completed",
"status": "failed",
"attempts": 8,
"last_status_code": 500,
"last_error": "HTTP 500",
"created_at": "2026-11-29T09:00:41.512Z",
"delivered_at": null
}
]
}status is pending (still being tried), succeeded or failed (every attempt failed).
Redeliver
POST /v1/webhook/deliveries/{id}/redeliver sends a delivery again as a fresh series of attempts, with the same payload and delivery id. Test events and redeliveries together are limited to 10 a minute and 100 an hour per workspace; past that the answer is 429 RATE_LIMITED.
Rotate the secret
POST /v1/webhook/secret gives the account webhook a new signing secret and returns it as webhook_secret. The old one stops working at once, so deploy the new secret to your receiver straight after. Without an account webhook the answer is 404.
const { webhook_secret } = await reelspan.webhooks.rotateSecret();webhook_secret = reelspan.webhooks.rotate_secret()["webhook_secret"]