Polling GET /v1/posts/{id} works, but it is wasteful and slow to react. Webhooks let Relaycast tell your server the moment a post is scheduled, published, partially failed or failed.
A webhook endpoint is a public URL, though, so anyone who finds it can send it requests. This tutorial shows how to receive Relaycast webhooks and verify that each one is genuine, recent, and not a duplicate.
1. Create a webhook rule
In the dashboard, open Settings → Notifications and add a rule:
- Choose Webhook as the channel and enter your endpoint URL. It must use
https://and resolve to a public address. - Pick the events you want:
post.scheduled,post.published,post.partial,post.failed. - Optionally limit the rule to posts that include specific accounts, or to scheduled posts only.
- Copy the signing secret. It starts with
whsec_. Store it as an environment variable on your server, for exampleRELAYCAST_WEBHOOK_SECRET.
Use Send test to send a sample event to your endpoint once it is running.
2. What a delivery looks like
Each delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
content-type | application/json |
x-webhook-id | A unique ID for this delivery. It stays the same if the delivery is retried. |
x-webhook-timestamp | Unix time in seconds when the delivery was signed. |
x-signature | Hex-encoded HMAC-SHA256 of {timestamp}.{body} using your signing secret. |
The body:
{
"id": "…",
"event": "post.partial",
"data": {
"postId": "8a1f…",
"status": "partial",
"scheduledFor": "2026-08-20T13:00:00.000Z",
"scheduled": true,
"content": "Our autumn range is live.",
"dashboardUrl": "https://app.your-domain.com/dashboard/posts",
"targets": [
{ "id": "…", "accountId": "…", "platform": "instagram", "handle": "acme", "status": "published", "url": "https://www.instagram.com/p/…", "error": null },
{ "id": "…", "accountId": "…", "platform": "tiktok", "handle": "acme", "status": "failed", "url": null, "error": "…" }
]
}
}Test deliveries include "test": true. data.scheduled is false for posts that were published immediately.
3. Verify the signature
The signature covers the timestamp and the exact bytes of the body. That has one important consequence: verify against the raw request body, before parsing it as JSON. Parsing and re-serialising can change whitespace or key order, and the signature will no longer match.
The steps:
- Read
x-signatureandx-webhook-timestamp. - Reject the request if the timestamp is more than a few minutes away from your current time. This stops an attacker from replaying an old, validly signed request.
- Compute
HMAC-SHA256(secret, timestamp + "." + rawBody)and hex-encode it. - Compare it to
x-signatureusing a constant-time comparison. - Only then parse the body and act on it.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.RELAYCAST_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300;
// express.raw keeps the body as a Buffer so we can verify the exact bytes.
app.post("/webhooks/relaycast", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.header("x-signature") ?? "";
const timestamp = req.header("x-webhook-timestamp") ?? "";
const body = req.body.toString("utf8");
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!timestamp || !Number.isFinite(age) || age > TOLERANCE_SECONDS) return res.status(400).send("stale");
const expected = createHmac("sha256", SECRET).update(`${timestamp}.${body}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) return res.status(401).send("bad signature");
const event = JSON.parse(body);
// Acknowledge quickly; do slow work in a background job.
res.sendStatus(200);
handleEvent(req.header("x-webhook-id")!, event);
});
app.listen(3000);import hashlib, hmac, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["RELAYCAST_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
@app.post("/webhooks/relaycast")
def social_publish_webhook():
signature = request.headers.get("x-signature", "")
timestamp = request.headers.get("x-webhook-timestamp", "")
body = request.get_data(as_text=True) # raw body, not request.json
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
abort(400, "stale")
expected = hmac.new(SECRET, f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401, "bad signature")
event = json.loads(body)
handle_event(request.headers["x-webhook-id"], event)
return "", 200Never compare signatures with ==. A normal string comparison stops at the first difference, which can leak timing information. Use timingSafeEqual in Node.js or hmac.compare_digest in Python.
4. Handle retries and duplicates
Any response outside the 2xx range counts as a failure, and the delivery is retried with exponential backoff. Requests that time out are retried too. Two habits keep this safe:
Respond fast. Return 200 as soon as the request is verified, and move slow work (database writes, calling other services) to a queue. A slow endpoint risks timing out and receiving the same event again.
De-duplicate by x-webhook-id. A retry of the same delivery keeps the same ID. Record the IDs you have processed and skip ones you have seen:
async function handleEvent(deliveryId: string, event: { event: string; data: any }) {
const isNew = await db.processedWebhooks.insertIfAbsent(deliveryId); // e.g. a unique index
if (!isNew) return;
if (event.event === "post.partial" || event.event === "post.failed") {
const failed = event.data.targets.filter((t: any) => t.status === "failed");
await alertTeam(event.data.postId, failed.map((t: any) => `${t.platform}: ${t.error}`));
}
}The timestamp check covers replays from outside; the delivery ID covers honest retries from us.
5. Test it
- Run your endpoint somewhere reachable over HTTPS. A tunnelling tool works well during development.
- Click Send test on the rule in Settings → Notifications. The dialog shows whether your endpoint accepted it.
- Publish a real post to one account and watch the
post.publishedevent arrive.
If verification fails, the usual causes are: verifying a parsed-and-reserialised body instead of the raw one, a trailing newline in the stored secret, or a proxy that modifies the body.
Rotating secrets
If a secret leaks, create a new webhook rule with a fresh secret, deploy the new secret to your server, then disable or delete the old rule.
The full event reference is in the notifications docs.
Ready to build?
Get an access key and publish your first post in minutes.