← All posts
Tutorials

Verify Signed Webhooks for Post Status Events

Receive post.published, post.partial and post.failed events from Relaycast and verify each request's HMAC-SHA256 signature and timestamp before trusting it.

Relaycast Team · August 18, 2026 · 3 min read

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:

  1. Choose Webhook as the channel and enter your endpoint URL. It must use https:// and resolve to a public address.
  2. Pick the events you want: post.scheduled, post.published, post.partial, post.failed.
  3. Optionally limit the rule to posts that include specific accounts, or to scheduled posts only.
  4. Copy the signing secret. It starts with whsec_. Store it as an environment variable on your server, for example RELAYCAST_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:

HeaderValue
content-typeapplication/json
x-webhook-idA unique ID for this delivery. It stays the same if the delivery is retried.
x-webhook-timestampUnix time in seconds when the delivery was signed.
x-signatureHex-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:

  1. Read x-signature and x-webhook-timestamp.
  2. 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.
  3. Compute HMAC-SHA256(secret, timestamp + "." + rawBody) and hex-encode it.
  4. Compare it to x-signature using a constant-time comparison.
  5. 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 "", 200

Never 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

  1. Run your endpoint somewhere reachable over HTTPS. A tunnelling tool works well during development.
  2. Click Send test on the rule in Settings → Notifications. The dialog shows whether your endpoint accepted it.
  3. Publish a real post to one account and watch the post.published event 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.

#webhooks#security#notifications#tutorial

Ready to build?

Get an access key and publish your first post in minutes.

Related posts