Webhooks for post events
Subscribe to events to get real-time notifications when posts are published, scheduled, or fail, and when a reviewer approves or rejects a post in an approval workflow. Webhooks are the right choice when you want to know about state changes without polling the API.
Event catalog
| Event | When it fires | data.status | Main payload fields |
|---|---|---|---|
post.scheduled | A post is created or updated into the scheduled state through the API (POST /posts/create or PATCH /posts/:id). Not sent for posts scheduled in the dashboard, or for a post that waits for approval | scheduled | scheduled_at. targets is empty and published_at is null |
post.published | A publish run ends with at least one channel live. Sent again after every retry run, manual or automatic | posted, or warning when some channels failed | published_at, and targets[] with platform, status, native_post_id, and error on a failed channel |
post.failed | A publish run ends with no channel live. An automatic retry can still follow | failed | targets[] with platform, status and error |
post.approved | The last step of a post's approval workflow approves it | scheduled, or posting when the publish time has passed | approval with status, decided_by and reason (null). targets is empty |
post.rejected | An approver rejects the post. It will not publish | rejected | approval with status, decided_by and the reason. targets is empty |
Every event uses the same envelope: id (the same value as the X-OmniSocials-Delivery header), type, created_at, and data with post_id, workspace_id, status, post_type (Post, Story or Reel), scheduled_at, published_at and targets. data.status is the stored status, so a published post reads posted, not published.
One post can send several events, for example post.failed and later post.published when a retry succeeds. Keep your state per data.post_id; see Retries and duplicate posts.
Creating a webhook
Terminal
Response:
JSON
The id is a UUID and the secret is a 64-character hex string returned exactly once on creation. Store the secret. You will need it to verify incoming webhook signatures.
Available events
Subscribe to any of the five events in the Event catalog. These are the only five valid events. Creating or updating a webhook with any other value (including a "*" wildcard) returns 400. There is no Social Inbox event (a new DM, comment, or mention) yet. Poll GET /inbox/conversations instead.
Partial publishes fire post.published, not a separate event. When a post succeeds on some platforms and fails on others, the payload's data.status is "warning" and the per-platform outcomes are in data.targets (each with its own status). Inspect targets to see exactly which platforms succeeded or failed.
Payload shape
JSON
data.targets holds one entry per selected platform. native_post_id is the platform's own post ID (present when that platform succeeded); error appears only on a non-successful target. data.status is the overall post status (posted, warning for a partial success, or failed). Always branch on the top-level type before accessing data.
post.scheduled, post.approved and post.rejected are delivered before anything is published, so their data.targets array is empty and published_at is null.
Approval events
post.approved and post.rejected use the same envelope and carry one more object, data.approval, that says how the review ended:
JSON
| Field | Value |
|---|---|
data.approval.status | approved or rejected |
data.approval.decided_by | User id of the approver who gave the last approval, or who rejected the post |
data.approval.reason | The reason the approver gave with a rejection. null on post.approved and when the approver gave no reason |
On post.approved, data.status is scheduled, or posting when the publish time has already passed. On post.rejected it is rejected. post.approved fires once, when the last step is approved; an approval of an earlier step sends no event.
The payload names the outcome only. For every step, every approver and the comment thread, call GET /posts/:id/approval with the post_id from the event.
Verifying signatures
Every outbound webhook call is signed with HMAC-SHA256 using your webhook's secret. Each delivery carries these headers:
| Header | Value |
|---|---|
X-OmniSocials-Signature | t=<unix_timestamp>,v1=<hex_signature> |
X-OmniSocials-Timestamp | The same unix timestamp, on its own |
X-OmniSocials-Event | The event type (e.g. post.published) |
X-OmniSocials-Webhook-Id | The webhook's ID |
X-OmniSocials-Delivery | A unique ID per event, stable across retry attempts. Use it to deduplicate. |
The signature is computed over `${timestamp}.${rawBody}` (the timestamp, a literal dot, then the raw JSON body), the same scheme Stripe uses. To verify: parse t and v1 out of the header, recompute the HMAC over timestamp.rawBody, and compare against v1. A mismatch means the request is not from OmniSocials. Reject it.
JavaScript
Verify against the raw request body, before any JSON parsing or reserialization. Always compare with a constant-time function like timingSafeEqual. Optionally, reject deliveries whose t timestamp is more than a few minutes old to guard against replay. Keep that window at 5 minutes or more: the timestamp is set when the event is created and reused on retries, so a retried delivery arrives with the original timestamp.
Delivery and retries
- Respond with any
2xxwithin 10 seconds. That counts as delivered. Anything else (including a timeout after 10 seconds) counts as a failure. Do your processing after you respond, not before. - Failed deliveries retry twice: the first retry after 2 seconds, the second after 5 more. Only
5xx,429, and network errors are retried; any other4xxfails immediately without a retry. - Delivery is at-least-once. If your endpoint processed the event but the response timed out, the retry delivers a duplicate. Deduplicate on
X-OmniSocials-Delivery; it stays the same across retries of one event. - Ordering is not guaranteed. Deliveries fire asynchronously and concurrently; a retrying delivery can arrive after a newer event. Order on the payload's
created_at. - After the final failed attempt the event is dropped. It is not re-sent later, and there is no replay endpoint. The webhook records the failure:
failure_countincrements andlast_failure(time, status, error) is stored; read both viaGET /webhooks/:id. A webhook is never disabled automatically, so a recovered endpoint receives the next event normally. Reconcile missed events by pollingGET /posts?status=. - Requests come with
User-Agent: OmniSocials-Webhooks/1.0and follow up to 3 redirects.
Local testing. Webhook URLs must be publicly reachable. For local development, expose your dev server with a tunnel (for example cloudflared tunnel or ngrok http 3000) and register the tunnel URL as the webhook.
Managing webhooks
All webhook endpoints require the webhooks:manage scope on your API key.
| Endpoint | Description |
|---|---|
GET /webhooks | List all webhooks for this key |
GET /webhooks/:id | Get a single webhook |
PATCH /webhooks/:id | Update URL, events, or is_active (re-enabling resets failure_count) |
DELETE /webhooks/:id | Delete a webhook |
POST /webhooks/:id/rotate-secret | Rotate the signing secret |
Rotating a secret immediately invalidates the old one. Update your verification code to use the new secret before rotating, or accept a short window of failed verifications.