# Webhooks for Post Events

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

**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 for this delivery attempt |

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')

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  )
}
```

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.

## Managing webhooks

| Endpoint | Description |
|----------|-------------|
| `GET /webhooks` | List all webhooks for this key |
| `GET /webhooks/:id` | Get a single webhook |
| `PATCH /webhooks/:id` | Update URL or events |
| `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.
