# Webhooks (/docs/guides/webhooks)



Overview [#overview]

Webhooks let FeedHorizon push events to your application as they happen.

Common use cases:

* Mark posts as published in your internal system
* Alert on failed publishing attempts
* Trigger downstream automation when accounts connect or disconnect

Supported events [#supported-events]

| Event                   | Sent when                                                                                                                                                                      |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `post.scheduled`        | A post was scheduled                                                                                                                                                           |
| `post.published`        | All targets of a post were published                                                                                                                                           |
| `post.failed`           | All targets of a post failed                                                                                                                                                   |
| `post.partial`          | Some targets were published, others failed                                                                                                                                     |
| `post.updated`          | Content, platforms or time of a not yet published post changed. `data.changes` lists each changed field with `from` and `to`                                                   |
| `post.deleted`          | A post was deleted                                                                                                                                                             |
| `post.target.published` | One account of a post was published, without waiting for the post's other accounts                                                                                             |
| `post.target.failed`    | One account of a post failed for good (after any automatic retries). `data.error` says why and who can fix it                                                                  |
| `account.connected`     | A social account was connected                                                                                                                                                 |
| `account.disconnected`  | A social account was disconnected                                                                                                                                              |
| `account.expiring`      | A connection ends in 7 days and the platform does not let us renew it (for example LinkedIn without refresh tokens). `data.expiresAt` says when. Ask the customer to reconnect |
| `account.needs_reauth`  | The connection stopped working (token expired or revoked). `data.reason` is `token_expired` or `refresh_rejected`. Posts to this account fail until it is reconnected          |

Create a webhook [#create-a-webhook]

```bash title="cURL"
curl -X POST https://feedhorizon.com/api/v1/webhooks \
  -H "Authorization: Bearer fh_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production endpoint",
    "url": "https://api.example.com/webhooks/feedhorizon",
    "events": ["post.published", "post.failed"]
  }'
```

The response contains the webhook and its signing secret (`whsec_…`). The secret is shown only once, so store it right away. An organization can have up to 10 webhooks.

Delivery format [#delivery-format]

FeedHorizon sends a `POST` request with a JSON body:

```json
{
    "id": "evt_3f2b9c0e4d5a4b6c8e7f1a2b3c4d5e6f",
    "event": "post.published",
    "timestamp": "2026-11-04T10:20:30.000Z",
    "data": {
        "postId": "post_9b2f4c1e6a3d4e8b9f0a1c2d3e4f5a6b",
        "profileId": "prof_2f9c1e4b6a3d4e8b9f0a1c2d3e4f5a6b",
        "status": "published"
    }
}
```

`id` is the same on every retry of one event. Use it to ignore duplicates.

Event data [#event-data]

**`post.published`, `post.partial`, `post.failed`** carry the post and one entry per account:

```json
{
    "postId": "post_9b2f…",
    "profileId": "prof_2f9c…",
    "status": "partially_failed",
    "targets": [
        { "targetId": "tgt_1a2b…", "accountId": "acc_3c4d…", "platform": "linkedin", "status": "published",
          "platformPostId": "urn:li:share:7654321", "platformUrl": "https://www.linkedin.com/feed/update/urn:li:share:7654321", "error": null },
        { "targetId": "tgt_5e6f…", "accountId": "acc_7a8b…", "platform": "twitter", "status": "failed",
          "platformPostId": null, "platformUrl": null,
          "error": { "code": "account_needs_reauth", "category": "auth_expired", "source": "user", "message": "The social account needs to be reconnected" } }
    ]
}
```

**`post.target.published`** and **`post.target.failed`** carry one account. With many accounts per post, use these to update your UI per account instead of waiting for the slowest one:

```json
{
    "postId": "post_9b2f…",
    "profileId": "prof_2f9c…",
    "targetId": "tgt_5e6f…",
    "accountId": "acc_7a8b…",
    "platform": "twitter",
    "status": "failed",
    "platformPostId": null,
    "platformUrl": null,
    "error": { "code": "platform_rejected", "category": "rejected", "source": "user", "message": "X publish failed (403): duplicate content" }
}
```

The codes in `error` are listed under [Publishing errors](/docs/api/errors#publishing-errors).

**`post.updated`** adds `data.changes`, one entry per changed field with `from` and `to`. **`account.expiring`** adds `data.expiresAt`, **`account.needs_reauth`** adds `data.reason`.

Each request carries these headers:

| Header                | Content                                |
| --------------------- | -------------------------------------- |
| `X-Webhook-Signature` | `t=<unix seconds>,v1=<hex signature>`  |
| `X-Webhook-Event`     | The event name                         |
| `X-Webhook-Id`        | The event id, same as `id` in the body |
| `X-Webhook-Delivery`  | The id of this delivery attempt series |

Verify signatures [#verify-signatures]

The signature is an HMAC-SHA256 of `<t>.<raw body>`, keyed with your webhook secret and hex encoded. Including the timestamp stops an attacker from replaying an old delivery: reject requests whose `t` is more than five minutes away from your clock.

```javascript title="Node.js"
import crypto from 'node:crypto';

export function verifyFeedHorizonSignature(rawBody, header, secret, toleranceSeconds = 300) {
    const parts = Object.fromEntries((header ?? '').split(',').map((p) => p.split('=')));
    const t = Number(parts.t);
    if (!t || !parts.v1) return false;
    if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

    const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
    const a = Buffer.from(expected, 'hex');
    const b = Buffer.from(parts.v1, 'hex');
    return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Always verify against the raw body exactly as received. Parsing and re-serializing the JSON changes the bytes and breaks the signature.

Test a webhook endpoint [#test-a-webhook-endpoint]

```bash title="cURL"
curl -X POST https://feedhorizon.com/api/v1/webhooks/WEBHOOK_ID/test \
  -H "Authorization: Bearer fh_YOUR_API_KEY"
```

Test deliveries use the event `webhook.test` and are signed like real ones.

Failure handling [#failure-handling]

* Any `2xx` response counts as success. The timeout is 10 seconds.
* A failed delivery is retried up to 5 attempts in total, after 1 minute, 5 minutes, 30 minutes and 2 hours.
* On success, `failCount` resets to `0`.
* After 10 consecutive failures the webhook is disabled (`isActive: false`). Re-enable it with `PATCH /webhooks/{id}`.

Best practices [#best-practices]

* Return `2xx` quickly, then process asynchronously
* Deduplicate on `id`, since a retry can arrive after you already processed the event
* Rotate secrets periodically
* Restrict access to the endpoint and always check the signature
