Skip to content
reelspanDocs

After you post

Webhooks

Get a signed POST on your server as each network's post goes live or fails, and when accounts connect or need reconnecting. Events, payloads, signatures, retries.

POSTapi.reelspan.com/v1/webhookTry it →

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"]
  }'
{
  "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_events to get all four. It also takes an object of booleans ({ "upload_completed": true }) and dotted names (social_account.connected). An unknown event name gets 400 INVALID_WEBHOOK.
  • Saving again changes the URL or events and keeps the signing secret. GET /v1/webhook reads the settings, secret included; DELETE /v1/webhook (or "channels": { "webhook": false }) removes the webhook.
  • The key needs the webhooks scope, 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"}'

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"
}
  • platform is the one network this event is about; media_type is video, photo, text or document.
  • title is the post's title; caption is the text this network got (its {network}_title, else title).
  • result says how it went: url and post_id when it went live, error and error_code when it failed. It can carry more keys from the network, such as first_comment. A test key post arrives with "success": true, "test": true, a post_id starting with test_ and no url.
  • request_id, job_id and external_id tie 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
});

Without 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));
}

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);
{
  "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();
POSTapi.reelspan.com/v1/webhookTry it →

Request body

application/json

  • channelsobject
    • webhookboolean
  • webhook_urlstring
  • webhook_eventsstring[] or object

    Array of event names or an object of booleans

Responses

200 Saved

{
  "success": true,
  "notifications": {}
}

400 Invalid input

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}

401 Invalid key

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}
GETapi.reelspan.com/v1/webhookTry it →

Responses

200 Settings

{
  "success": true,
  "notifications": {}
}

400 Invalid input

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}

401 Invalid key

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}
DELETEapi.reelspan.com/v1/webhookTry it →

Responses

200 Removed

{
  "success": true,
  "message": "string"
}

400 Invalid input

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}

401 Invalid key

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}
POSTapi.reelspan.com/v1/webhook/secretTry it →

Responses

200 New secret

{
  "success": true,
  "webhook_secret": "string"
}

400 Invalid input

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}

401 Invalid key

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}
POSTapi.reelspan.com/v1/webhook/testTry it →

Responses

200 Queued

{
  "success": true,
  "delivery_id": "string"
}

400 Invalid input

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}

401 Invalid key

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}
GETapi.reelspan.com/v1/profile-webhookTry it →

Parameters

  • profile_usernamestring · queryrequired

Responses

200 Settings

{
  "success": true,
  "webhook": {}
}

400 Invalid input

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}

401 Invalid key

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}
POSTapi.reelspan.com/v1/profile-webhookTry it →

Request body

application/json

  • profile_usernamestringrequired
  • webhook_urlstringrequired
  • webhook_eventsstring[] or object

    Array of event names or an object of booleans

Responses

200 Saved

{
  "success": true,
  "webhook": {}
}

400 Invalid input

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}

401 Invalid key

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}
DELETEapi.reelspan.com/v1/profile-webhookTry it →

Request body

application/json

  • profile_usernamestringrequired

Responses

200 Removed

{
  "success": true,
  "message": "string"
}

400 Invalid input

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}

401 Invalid key

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}
GETapi.reelspan.com/v1/webhook/deliveriesTry it →

Parameters

  • limitinteger · query

Responses

200 Deliveries

{
  "success": true,
  "deliveries": [
    {}
  ]
}
POSTapi.reelspan.com/v1/webhook/deliveries/{id}/redeliverTry it →

Parameters

  • idstring · pathrequired

Responses

200 Queued

{
  "success": true
}

400 Invalid input

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}

401 Invalid key

{
  "success": false,
  "message": "string",
  "error": "string",
  "error_code": "string"
}

Something unclear or missing? Write to [email protected] and we’ll fix the page.