# Webhooks for Post Events

import { TechArticle } from "../components/TechArticle";

<TechArticle slug="webhooks" fm={frontmatter} />

Subscribe to events to get real-time notifications when posts are published, scheduled, or fail. Webhooks are the right choice when you want to know about state changes without polling the API.

## Creating a webhook

```bash
curl -X POST https://api.omnisocials.com/v1/webhooks \
  -H "Authorization: Bearer $OMNISOCIALS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks/omnisocials",
    "events": ["post.published", "post.failed"]
  }'
```

Response:

```json
{
  "data": {
    "id": "5f3b0c1e-9a2d-4e77-b1c4-8a2f6d9e0b12",
    "url": "https://your-app.com/webhooks/omnisocials",
    "events": ["post.published", "post.failed"],
    "is_active": true,
    "created_at": "2026-04-10T14:22:00Z",
    "secret": "a1b2c3d4e5f6...c7d8e9f0"
  },
  "message": "Webhook created. Save the secret - it will only be shown once."
}
```

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

| Event | When it fires |
|-------|---------------|
| `post.scheduled` | A post moves into the scheduled state |
| `post.published` | A post publishes successfully to at least one platform |
| `post.failed` | A post fails to publish on every selected platform |

These are the only three 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`](/inbox) 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
{
  "id": "8c1e0a2b-4d5f-4a67-9b0c-1d2e3f4a5b6c",
  "type": "post.published",
  "created_at": "2026-04-10T14:22:00Z",
  "data": {
    "post_id": "123",
    "workspace_id": "456",
    "status": "posted",
    "post_type": "post",
    "scheduled_at": "2026-04-10T14:20:00Z",
    "published_at": "2026-04-10T14:22:00Z",
    "targets": [
      {
        "platform": "instagram",
        "status": "success",
        "native_post_id": "18023456789012345"
      },
      {
        "platform": "linkedin",
        "status": "failed",
        "native_post_id": null,
        "error": "The LinkedIn token has expired. Reconnect the account."
      }
    ]
  }
}
```

`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`.

## 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
import crypto from 'crypto'

function verifyWebhook(rawBody, signatureHeader, secret) {
  // signatureHeader looks like "t=1744294920,v1=abc123..."
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((kv) => kv.split('='))
  )
  const { t: timestamp, v1: signature } = parts
  if (!timestamp || !signature) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`, 'utf8')
    .digest('hex')

  // timingSafeEqual throws when lengths differ, so check first
  const sigBuf = Buffer.from(signature)
  const expBuf = Buffer.from(expected)
  if (sigBuf.length !== expBuf.length) return false

  return crypto.timingSafeEqual(sigBuf, expBuf)
}
```

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 `2xx` within 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 other `4xx` fails 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_count` increments and `last_failure` (time, status, error) is stored; read both via `GET /webhooks/:id`. A webhook is never disabled automatically, so a recovered endpoint receives the next event normally. Reconcile missed events by polling `GET /posts?status=`.
- Requests come with `User-Agent: OmniSocials-Webhooks/1.0` and 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.
