Docs › API reference › Webhooks
Signed webhooks: what happened to a post
Get a signed HTTPS call when a post publishes or fails, or a channel needs attention, and verify the signature in your code.
A webhook is a URL on your server that OmniPosts calls when something happens. Use it instead of polling for results. Each call carries one event, and is signed so you can prove it came from OmniPosts.
POST /v1/webhooks
Create a webhook
POST /v1/webhooks registers a URL and the events you want.
The answer is 201:
Your URL must pass these checks, or you get 422 invalid_webhook with one sentence saying why:
- It uses
https://, with no user name or password, on port 443 or 8443. - The host is a public name or address that resolves. Not allowed:
localhost, names with a single label, names ending in.local,.localhostor.internal, IP addresses in private, loopback, link-local or similar ranges, and names that resolve to such addresses. eventsis not empty and only uses the names below.
OmniPosts checks the URL again before every delivery, in case a name was later pointed at a private address.
curl https://omniposts.dev/v1/webhooks \
-H "Authorization: Bearer omniposts_..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://hooks.example.com/omniposts", "events": ["post.published", "post.failed"] }'
{
"id": "0192a3b4-0000-7000-8000-0000000000e1",
"url": "https://hooks.example.com/omniposts",
"events": ["post.published", "post.failed"],
"active": true,
"consecutiveFailures": 0,
"createdAt": "2026-10-14T09:00:00Z",
"disabledAt": null,
"secret": "whsec_..."
}
GET /v1/webhooks
List and delete
GET /v1/webhooks lists your webhooks, with the same fields as above but never the secret.
DELETE /v1/webhooks/{id} removes a webhook and its delivery log. The answer is 204. An id that is not yours is
404.
Events
| Event | When |
|---|---|
post.published |
A channel's result reached published |
post.failed |
A channel's result ended as failed |
token.expired |
A channel needs to be reconnected |
channel.disconnected |
A channel was disconnected |
A webhook receives only the events you chose, from your own organization.
The payload
Every delivery is a POST with a JSON body of the same shape:
id identifies this delivery and does not change across retries. data for post.failed has status: "failed"
and a failure object in failure.
token.expired and channel.disconnected carry the channel instead:
reason appears on token.expired only. Payloads never contain tokens or raw platform responses.
{
"id": "0192a3b4-0000-7000-8000-0000000000e9",
"type": "post.published",
"createdAt": "2026-10-20T09:30:05Z",
"data": {
"targetId": "0192a3b4-0000-7000-8000-0000000000f2",
"groupId": "0192a3b4-0000-7000-8000-0000000000f1",
"channelId": "0192a3b4-0000-7000-8000-000000000001",
"providerId": "bluesky",
"status": "published",
"externalUrl": "https://example.invalid/post/1",
"publishedAt": "2026-10-20T09:30:04Z",
"failure": null
}
}
{
"id": "0192a3b4-0000-7000-8000-0000000000ea",
"type": "token.expired",
"createdAt": "2026-10-21T07:00:00Z",
"data": { "channelId": "0192a3b4-0000-7000-8000-000000000001", "providerId": "bluesky", "reason": "..." }
}
How a delivery is signed
OmniPosts follows the Standard Webhooks scheme. Each request has these headers:
| Header | Value |
|---|---|
webhook-id |
The delivery id. Same as id in the body, and the same on every retry |
webhook-timestamp |
Unix time in seconds when this attempt was sent. It is new on each attempt |
webhook-signature |
v1, followed by a base64 signature |
The signature is an HMAC-SHA256 over the string {webhook-id}.{webhook-timestamp}.{body}, where {body} is the
request body exactly as received. The key is the base64-decoded part of your secret after the whsec_
prefix, not the secret text itself.
Verify the signature
Do three things on every request, and reject it if any fails:
- Check
webhook-timestampis recent (five minutes is a good window). This stops an old request being replayed. - Compute the signature from the raw bytes of the body. Do not parse and re-serialise the JSON first: the signature covers the exact bytes OmniPosts sent.
- Compare in constant time.
A Node.js version for Express. express.raw keeps the body as a Buffer:
The same in Python (standard library only). body must be the raw bytes your framework gives you:
Answer with any 2xx status as soon as you have stored the event, and do slow work afterwards. Header names
are case-insensitive: in some frameworks they arrive in lower case, in others as Webhook-Id.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.OMNIPOSTS_WEBHOOK_SECRET; // "whsec_..."
const TOLERANCE_SECONDS = 300;
function verify(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
const expected = crypto.createHmac("sha256", key).update(signed).digest();
// The header may hold several space-separated signatures, each as "v1,<base64>".
for (const part of signatures.split(" ")) {
const [version, value] = part.split(",");
if (version !== "v1" || !value) continue;
const given = Buffer.from(value, "base64");
if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) {
return true;
}
}
return false;
}
const app = express();
app.post("/omniposts", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(SECRET, req.headers, req.body)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
// Handle event.type and event.data. Use event.id to ignore a repeat.
res.sendStatus(204);
});
app.listen(3000);
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures):
return False
try:
if abs(time.time() - int(timestamp)) > tolerance:
return False
except ValueError:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = hmac.new(key, signed, hashlib.sha256).digest()
for part in signatures.split(" "):
version, _, value = part.partition(",")
if version != "v1" or not value:
continue
try:
given = base64.b64decode(value)
except ValueError:
continue
if hmac.compare_digest(given, expected):
return True
return False
Retries and disabling
- OmniPosts waits up to 10 seconds for your answer and does not follow redirects. Any
2xxis a success. - Anything else (another status, a timeout, a redirect, a connection error) is a failed attempt, and OmniPosts tries again after a growing delay: 30 seconds, 2 minutes, 8 minutes, 32 minutes, then every 2 hours.
- There are 8 attempts in all. After the eighth the delivery's status is
failedand OmniPosts stops. - OmniPosts counts failed attempts in a row across all deliveries to a webhook. A success resets the count to zero.
After 50 in a row the webhook is switched off:
activebecomesfalseanddisabledAtis set. A switched off webhook gets no more events. Fix your endpoint, then delete the webhook and create a new one. - Because of retries, you can get the same event more than once. Keep the
idof events you have handled and ignore repeats.
GET /v1/webhooks/{id}/deliveries
See what was delivered
GET /v1/webhooks/{id}/deliveries lists the delivery log for one webhook, newest first. It is paginated: see
Pagination.
status is pending (still trying), delivered or failed. responseStatus is your server's last answer, or
null if there was none (a timeout, for example). attempt counts tries so far. The delivery id matches the
webhook-id header and the id in the payload, so you can find a delivery from your own logs.
{
"items": [
{
"id": "0192a3b4-0000-7000-8000-0000000000e9",
"event": "post.published",
"status": "delivered",
"responseStatus": 204,
"attempt": 1,
"createdAt": "2026-10-20T09:30:05Z"
}
],
"nextCursor": null
}