Events

Webhook events

Signed HTTP callbacks for post.scheduled, post.published, post.partial and post.failed, and how to verify them.

Relaycast can POST a signed JSON event to your server when a post is scheduled, published, partially published or failed. Webhooks are configured in the dashboard, not through the API.

Set up a webhook

  1. Open Settings → Notifications in the dashboard.
  2. Add a rule with the Webhook channel.
  3. Enter your endpoint URL. It must be https and resolve to a public address.
  4. Copy the signing secret (it starts with whsec_). You need it to verify signatures.
  5. Pick the events, and optionally limit the rule to some accounts or to scheduled posts only.
  6. Use Send test to deliver a sample post.published event.

See Notifications & Webhooks for email notifications and rule options.

Events

EventSent when
post.scheduledA post is created with scheduledFor (not sent for publishNow posts).
post.publishedEvery target of the post was published.
post.partialThe post finished with some targets published and some failed.
post.failedEvery target of the post failed.

Each event is sent at most once per rule and post. A rule limited to certain accounts fires when the post targets at least one of them. A rule with only scheduled posts ignores posts sent with publishNow.

Request

POST https://your-server.example.com/webhooks/relaycast
content-type: application/json
x-webhook-id: 2f6c0b1e-7d4a-4f3e-9b8c-1a2d3e4f5a6b_a3f8c2d1-9b7e-4c6a-8f5d-2e1b0c9a8d7f_post.published
x-webhook-timestamp: 1793631606
x-signature: 5b1f0c3e9a7d2b8e4f6a1c0d9e8b7a6f5c4d3e2b1a0f9e8d7c6b5a4f3e2d1c0b

Headers

HeaderDescription
x-webhook-idUnique delivery ID. It is the same on every retry of the same delivery, so use it to ignore duplicates. Test deliveries use test_<random>.
x-webhook-timestampUnix time in seconds when the request was signed.
x-signatureHex-encoded HMAC-SHA256 of `${timestamp}.${rawBody}` with your signing secret.
content-typeapplication/json

Body

post.published
{
  "id": "2f6c0b1e-7d4a-4f3e-9b8c-1a2d3e4f5a6b_a3f8c2d1-9b7e-4c6a-8f5d-2e1b0c9a8d7f_post.published",
  "event": "post.published",
  "data": {
    "postId": "a3f8c2d1-9b7e-4c6a-8f5d-2e1b0c9a8d7f",
    "status": "published",
    "scheduledFor": "2026-11-02T15:00:00.000Z",
    "scheduled": true,
    "content": "Our new release is live. Faster uploads, a calendar view and more.",
    "dashboardUrl": "https://social-publish-kappa.vercel.app/dashboard/posts",
    "targets": [
      {
        "id": "e1d2c3b4-a596-4877-8695-a4b3c2d1e0f9",
        "accountId": "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f",
        "platform": "twitter",
        "handle": "acme",
        "status": "published",
        "url": "https://x.com/acme/status/1852700000000000000",
        "error": null
      },
      {
        "id": "f0e9d8c7-b6a5-4493-8281-706f5e4d3c2b",
        "accountId": "3c8d1e2f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
        "platform": "linkedin",
        "handle": "acme-inc",
        "status": "published",
        "url": "https://www.linkedin.com/feed/update/urn:li:share:7260000000000000000/",
        "error": null
      }
    ]
  }
}
FieldTypeDescription
idstringDelivery ID (same as x-webhook-id).
eventstringpost.scheduled, post.published, post.partial or post.failed.
testbooleantrue for Send test deliveries; omitted otherwise.
data.postIdstringThe post ID. Fetch the full post with GET /v1/posts/{id}.
data.statusstringThe post status: scheduled, publishing, published, partial or failed.
data.scheduledForstringWhen the post was or will be sent (ISO 8601).
data.scheduledbooleanfalse when the post was sent with publishNow.
data.contentstringThe shared post text.
data.dashboardUrlstringLink to the Posts page in the dashboard.
data.targets[].idstringTarget ID.
data.targets[].accountIdstringAccount ID.
data.targets[].platformstringPlatform ID.
data.targets[].handlestringAccount handle.
data.targets[].statusstringqueued, publishing, published or failed.
data.targets[].urlstring | nullPublic URL of the published post, if any.
data.targets[].errorstring | nullError message for a failed target.

The data object is read when the delivery is sent, so a retried delivery carries the post's current state.

For post.scheduled, data.status is scheduled, targets are queued, and url is null. For post.partial and post.failed, check targets[].error:

post.partial (data.targets)
[
  { "id": "e1d2c3b4-a596-4877-8695-a4b3c2d1e0f9", "accountId": "6f1c2a7e-4b8d-4f7a-9a51-2d3c4b5a6e7f", "platform": "twitter", "handle": "acme", "status": "published", "url": "https://x.com/acme/status/1852700000000000000", "error": null },
  { "id": "c9b8a7f6-e5d4-4c3b-a291-807f6e5d4c3b", "accountId": "1d7e3f52-8c4a-4b9e-a0f1-7e6d5c4b3a29", "platform": "instagram", "handle": "acme", "status": "failed", "url": null, "error": "Instagram only accepts JPEG images" }
]

Verify the signature

Always verify the signature before trusting a webhook:

  1. Read the raw request body, before any JSON parsing.
  2. Reject the request if x-webhook-timestamp is more than a few minutes from your clock (5 minutes is a good tolerance). This blocks replayed requests.
  3. Compute HMAC-SHA256(secret, timestamp + "." + rawBody) as hex.
  4. Compare it with x-signature using a constant-time comparison.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const SECRET = process.env.RELAYCAST_WEBHOOK_SECRET!; // whsec_...
const TOLERANCE_SECONDS = 5 * 60;

function verify(rawBody: Buffer, timestamp: string | undefined, signature: string | undefined): boolean {
  if (!timestamp || !signature) return false;
  const ts = Number(timestamp);
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", SECRET).update(`${timestamp}.`).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signature, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

const app = express();
const seen = new Set<string>(); // use a database or cache in production

app.post("/webhooks/relaycast", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.header("x-webhook-timestamp"), req.header("x-signature"))) {
    return res.status(401).send("invalid signature");
  }
  const deliveryId = req.header("x-webhook-id")!;
  if (seen.has(deliveryId)) return res.sendStatus(200); // duplicate retry
  seen.add(deliveryId);

  const event = JSON.parse(req.body.toString("utf8"));
  if (event.event === "post.failed" || event.event === "post.partial") {
    for (const t of event.data.targets) if (t.status === "failed") console.warn(t.platform, t.error);
  }
  res.sendStatus(200); // respond quickly; do slow work asynchronously
});

app.listen(3001);
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

SECRET = os.environ["RELAYCAST_WEBHOOK_SECRET"].encode()  # whsec_...
TOLERANCE_SECONDS = 5 * 60

app = Flask(__name__)
seen = set()  # use a database or cache in production


def verify(raw_body: bytes, timestamp: str | None, signature: str | None) -> bool:
    if not timestamp or not signature:
        return False
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/webhooks/relaycast")
def social_publish_webhook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    if not verify(raw, request.headers.get("x-webhook-timestamp"), request.headers.get("x-signature")):
        abort(401)
    delivery_id = request.headers["x-webhook-id"]
    if delivery_id in seen:
        return "", 200  # duplicate retry
    seen.add(delivery_id)

    event = json.loads(raw)
    if event["event"] in ("post.failed", "post.partial"):
        for t in event["data"]["targets"]:
            if t["status"] == "failed":
                print(t["platform"], t["error"])
    return "", 200

Compute the signature over the exact bytes you received. Re-serializing parsed JSON changes whitespace and key order, and the signature won't match.

Responding and retries

  • Return any 2xx status to acknowledge the delivery. Any other status, a connection error, or no response within 10 seconds counts as a failure.
  • Failed deliveries are retried up to 8 attempts in total with exponential backoff starting at 10 seconds (10 s, 20 s, 40 s, … between attempts, about 20 minutes overall).
  • Redirects are not followed. Point the rule at the final URL.
  • If the URL starts resolving to a private or internal address, delivery stops without further retries.
  • Deliveries for a rule that has been deleted or disabled are dropped.

Retries reuse the same x-webhook-id, with a new x-webhook-timestamp and x-signature, so make your handler idempotent on the delivery ID.

This page is also available as Markdown.