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
- Open Settings → Notifications in the dashboard.
- Add a rule with the Webhook channel.
- Enter your endpoint URL. It must be
httpsand resolve to a public address. - Copy the signing secret (it starts with
whsec_). You need it to verify signatures. - Pick the events, and optionally limit the rule to some accounts or to scheduled posts only.
- Use Send test to deliver a sample
post.publishedevent.
See Notifications & Webhooks for email notifications and rule options.
Events
| Event | Sent when |
|---|---|
post.scheduled | A post is created with scheduledFor (not sent for publishNow posts). |
post.published | Every target of the post was published. |
post.partial | The post finished with some targets published and some failed. |
post.failed | Every 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: 5b1f0c3e9a7d2b8e4f6a1c0d9e8b7a6f5c4d3e2b1a0f9e8d7c6b5a4f3e2d1c0bHeaders
| Header | Description |
|---|---|
x-webhook-id | Unique 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-timestamp | Unix time in seconds when the request was signed. |
x-signature | Hex-encoded HMAC-SHA256 of `${timestamp}.${rawBody}` with your signing secret. |
content-type | application/json |
Body
{
"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
}
]
}
}| Field | Type | Description |
|---|---|---|
id | string | Delivery ID (same as x-webhook-id). |
event | string | post.scheduled, post.published, post.partial or post.failed. |
test | boolean | true for Send test deliveries; omitted otherwise. |
data.postId | string | The post ID. Fetch the full post with GET /v1/posts/{id}. |
data.status | string | The post status: scheduled, publishing, published, partial or failed. |
data.scheduledFor | string | When the post was or will be sent (ISO 8601). |
data.scheduled | boolean | false when the post was sent with publishNow. |
data.content | string | The shared post text. |
data.dashboardUrl | string | Link to the Posts page in the dashboard. |
data.targets[].id | string | Target ID. |
data.targets[].accountId | string | Account ID. |
data.targets[].platform | string | Platform ID. |
data.targets[].handle | string | Account handle. |
data.targets[].status | string | queued, publishing, published or failed. |
data.targets[].url | string | null | Public URL of the published post, if any. |
data.targets[].error | string | null | Error 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:
[
{ "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:
- Read the raw request body, before any JSON parsing.
- Reject the request if
x-webhook-timestampis more than a few minutes from your clock (5 minutes is a good tolerance). This blocks replayed requests. - Compute
HMAC-SHA256(secret, timestamp + "." + rawBody)as hex. - Compare it with
x-signatureusing 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 "", 200Compute 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
2xxstatus 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.