Get signed HTTP notifications when posts are scheduled, published, partially published or failed, and when accounts need attention.
Webhooks tell your server when something happens, so you do not have to poll. Subscriptions are managed with the notifications API or in Dashboard → Webhooks.
| Event | Sent when |
|---|---|
post.scheduled | A post is scheduled (created with scheduledFor, a draft is scheduled, or a retry is scheduled). |
post.published | Every target published without warnings. |
post.partial | Some targets failed or published with a warning. |
post.failed | Every target failed. |
account.reauthorization_required | An account must be reconnected before it can post again. |
account.disconnected | An account was disconnected. |
curl -s -X POST https://api.betterpost.dev/v1/notifications \
-H "x-access-key: $BETTERPOST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "webhook",
"eventTypes": ["post.published", "post.partial", "post.failed"],
"webhook": { "url": "https://app.example.com/webhooks/betterpost" }
}'The response (201) contains the signing secret, whsec_.... It is shown only once: store it now. Later reads
show it masked, as whsec_•••• followed by its last four characters.
Options:
profileId: only events for that profile's accounts. Post events then list only that profile's targets in
data.platforms, and are skipped if the post has none.onlyScheduledPosts: true: final post events (published, partial, failed) only for posts that had a
scheduled time. post.scheduled and account events are unaffected.enabled: false: pause deliveries without deleting the subscription.The URL must be https and resolve to a public address (no localhost or private networks).
Every delivery is a POST with a JSON body:
{
"version": "2026-10-21",
"id": "evt_3e7v1n5t9q2w6r0y4u8i1o5p",
"type": "post.published",
"createdAt": "2026-10-21T14:10:02.580Z",
"projectId": "proj_0r5t8y2u6i9o3p7a1s4d8f2g",
"profileId": null,
"data": {
"post": {
"id": "post_8f3k2m9q1x7v4n6b0c5d2a1z",
"status": "published",
"content": "Hello from BetterPost",
"scheduledFor": null,
"publishedAt": "2026-10-21T14:10:02.551Z",
"mediaItems": [],
"links": {
"betterpost": "https://betterpost.dev/dashboard/posts/post_8f3k2m9q1x7v4n6b0c5d2a1z",
"api": "https://api.betterpost.dev/v1/posts/post_8f3k2m9q1x7v4n6b0c5d2a1z"
}
},
"platforms": [
{
"platform": "twitter",
"integrationId": "acc_2x9c4v7b1n8m3k5j0h6g2f4d",
"profileId": null,
"status": "published",
"platformPostId": "sbx_1849203756184930275",
"platformPostUrl": "https://betterpost.dev/sandbox/p/twitter/sbx_1849203756184930275",
"publishedAt": "2026-10-21T14:10:02.551Z",
"errorMessage": null,
"warningMessage": null
}
]
}
}profileId at the top is the subscription's profile filter (null for project-wide subscriptions).data.post.mediaItems[] entries have type, url, thumbnail, filename, size and mimeType (unknown
values are null).post.scheduled, targets have status: "pending".Account events carry data.account instead of data.post and data.platforms:
{
"version": "2026-10-21",
"id": "evt_9t1y5u3i7o0p4a8s2d6f1g5h",
"type": "account.reauthorization_required",
"createdAt": "2026-10-21T15:00:00.000Z",
"projectId": "proj_0r5t8y2u6i9o3p7a1s4d8f2g",
"profileId": null,
"data": {
"account": {
"id": "acc_2x9c4v7b1n8m3k5j0h6g2f4d",
"platform": "twitter",
"platformUserId": "sbx_4829105736",
"username": "@demo_brand",
"displayName": "Demo Brand",
"status": "reconnect_required",
"reason": "sandbox_simulated"
}
}
}For account.disconnected, status is disconnected and reason is null.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | BetterPost-Webhooks/1.0 |
X-BetterPost-Event-Id | The event id (same as id in the body). Identical on every retry: use it to deduplicate. |
X-BetterPost-Event-Type | The event type, for example post.published. |
X-BetterPost-Timestamp | Unix time in seconds when this attempt was signed. |
X-BetterPost-Signature | sha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with your secret. |
Compute the HMAC over the raw request body (before any JSON parsing), compare it in constant time, and reject old timestamps to block replays.
Worked example: with secret whsec_9QeM2xT7kL4pZ8vB1nR6cW3yH5jD0sFa, timestamp 1792591200 and raw body
{"version":"2026-10-21","id":"evt_3e7v1n5t9q2w6r0y4u8i1o5p","type":"post.published"}, the string to sign is the
timestamp, a dot, then the body, and the header is
sha256=d2d7bf2265043a4afadcca00d8a6214d5c616dcf141f6a15b1385b40221b3d61.
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.BETTERPOST_WEBHOOK_SECRET;
const seen = new Set(); // use your database in production
function isValid(rawBody, timestamp, signature) {
if (!timestamp || !signature) return false;
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the exact bytes that were signed.
app.post("/webhooks/betterpost", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!isValid(raw, req.get("X-BetterPost-Timestamp"), req.get("X-BetterPost-Signature"))) {
return res.sendStatus(401);
}
const event = JSON.parse(raw);
if (!seen.has(event.id)) {
seen.add(event.id);
console.log(event.type, event.data.post?.id ?? event.data.account?.id);
}
res.sendStatus(200);
});
app.listen(3000);import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
SECRET = os.environ["BETTERPOST_WEBHOOK_SECRET"].encode()
def is_valid(raw: bytes, timestamp: str, signature: str) -> bool:
if not timestamp.isdigit() or not signature:
return False
signed = timestamp.encode() + b"." + raw
expected = "sha256=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(timestamp)) <= 300
return fresh and hmac.compare_digest(signature, expected)
@app.post("/webhooks/betterpost")
async def betterpost_webhook(request: Request):
raw = await request.body()
if not is_valid(
raw,
request.headers.get("x-betterpost-timestamp", ""),
request.headers.get("x-betterpost-signature", ""),
):
raise HTTPException(status_code=401)
event = json.loads(raw)
print(event["type"], event["id"])
return {"received": True}2xx response within 10 seconds counts as delivered. Anything else (other status codes, redirects,
timeouts, connection errors) is a failure.Respond quickly with 2xx and do slow work in a background job.
Send a sample signed post.published event to a URL before saving a subscription:
curl -s -X POST https://api.betterpost.dev/v1/notifications/test \
-H "x-access-key: $BETTERPOST_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "channel": "webhook", "webhook": { "url": "https://app.example.com/webhooks/betterpost" } }'{
"success": true,
"message": "Test event delivered (HTTP 200). The sample is signed with a temporary secret, so its signature can't be verified with a saved subscription secret."
}The test event uses the real headers and payload format, but it is signed with a temporary secret (no subscription exists yet), so use it to check reachability and parsing, not your signature check. The test is not retried and is not saved.