Events
Notifications & Webhooks
Get an email or a signed webhook when posts are scheduled, published, partially fail or fail.
Notification rules tell you when something happens to a post, so you don't have to poll GET /v1/posts/{id}. Each rule sends either an email or a webhook (an HTTPS POST signed with HMAC-SHA256).
Events
| Event | Fires when |
|---|---|
post.scheduled | A post is created with scheduledFor. Posts sent with publishNow don't fire this. |
post.published | Every target of a post published. |
post.partial | A post finished with some targets published and some failed. |
post.failed | Every target of a post failed. |
The last three fire once per post, when it reaches its final state. A post that's retrying on one platform hasn't finished yet, so you won't get an event until it does.
Create a rule
Rules are managed in the dashboard under Settings โ Notifications at https://social-publish-kappa.vercel.app/dashboard/settings?tab=notifications. Only project owners and admins can manage them.
- Click New notification.
- Choose the events.
- Choose which profiles it applies to: all profiles, or specific accounts. A rule fires if the post targets at least one of the chosen accounts.
- Optionally tick Only scheduled posts to skip posts sent with
publishNow. - Pick the channel: email recipients (up to 20 addresses), or a webhook URL.
- Use Send test to try it, then save.
Rules can be turned off and on without deleting them. There's no public API for managing rules.
Email notifications show the post text, each platform's result with a link to the live post or the error, and a button to open the post in the dashboard.
Email delivery needs an email provider configured on the deployment. If it isn't, the dashboard says so and email rules are skipped.
Webhooks
Webhook URLs must be public https:// URLs. URLs with credentials in them, localhost, and hosts that resolve to private, loopback or link-local addresses are rejected, both when you save the rule and again before every delivery. Redirects are not followed.
When you create a webhook rule, the dashboard shows its signing secret (it starts with whsec_). Copy it then: it's stored encrypted and can't be shown again.
Request
Each delivery is a POST with a JSON body:
POST /hooks/relaycast HTTP/1.1
Content-Type: application/json
User-Agent: SocialPublish-Webhooks/1.0
x-webhook-id: 9b1f..._5e6f7a8b-..._post.published
x-webhook-timestamp: 1791203000
x-signature: 3c5a0e9f4b...{
"id": "9b1f..._5e6f7a8b-..._post.published",
"event": "post.published",
"data": {
"postId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"status": "published",
"scheduledFor": "2026-10-05T14:00:00.000Z",
"scheduled": true,
"content": "We just shipped dark mode ๐",
"dashboardUrl": "https://social-publish-kappa.vercel.app/dashboard/posts",
"targets": [
{
"id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"accountId": "8f1c2a4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b",
"platform": "linkedin",
"handle": "jane-doe",
"status": "published",
"url": "https://www.linkedin.com/feed/update/urn:li:share:...",
"error": null
}
]
}
}| Field | Description |
|---|---|
id | Delivery ID, also sent as x-webhook-id. It's the same on every retry of this delivery, so use it to de-duplicate. |
event | One of the events above. |
test | true for deliveries sent with Send test; absent otherwise. |
data.status | The post's status. |
data.scheduled | false if the post was sent with publishNow. |
data.targets[] | One entry per account, with its status, url of the live post, or error message. |
Test deliveries use a sample post.published payload with placeholder IDs.
Verify the signature
The x-signature header is the hex-encoded HMAC-SHA256 of {timestamp}.{raw body}, keyed with your signing secret, where timestamp is the x-webhook-timestamp header (Unix seconds). Compute it over the raw request body, before any JSON parsing, and compare in constant time. Reject old timestamps to block replays.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(rawBody: string, headers: Headers, secret: string) {
const timestamp = headers.get("x-webhook-timestamp") ?? "";
const signature = headers.get("x-signature") ?? "";
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; // 5 minutes
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
return fresh && a.length === b.length && timingSafeEqual(a, b);
}import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("x-webhook-timestamp", "")
signature = headers.get("x-signature", "")
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
try:
fresh = abs(time.time() - int(timestamp)) < 300 # 5 minutes
except ValueError:
return False
return fresh and hmac.compare_digest(signature, expected)Respond and retries
Return any 2xx status within 10 seconds to acknowledge a delivery. Anything else, including timeouts and redirects, counts as a failure, and the delivery is retried with exponential backoff (starting at about 10 seconds), up to 8 attempts.
- Respond quickly and do slow work asynchronously.
- De-duplicate on
x-webhook-id, since a delivery can arrive more than once. - Don't rely on ordering. Use
data.statusand the target statuses as the source of truth, or fetch the post withGET /v1/posts/{id}.
See Webhook events for the payload reference.
Credit usage emails
Separately from notification rules, project owners and admins get emails when the project's credits run low. Turn them on or off under Settings โ General. See Credits & Usage.
This page is also available as Markdown.