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.

Updated

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, .localhost or .internal, IP addresses in private, loopback, link-local or similar ranges, and names that resolve to such addresses.
  • events is 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:

  1. Check webhook-timestamp is recent (five minutes is a good window). This stops an old request being replayed.
  2. 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.
  3. 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 2xx is 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 failed and 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: active becomes false and disabledAt is 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 id of 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
}