Webhooks
Subscribe to FeedHorizon events and verify deliveries securely.
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
| 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
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
FeedHorizon sends a POST request with a JSON body:
{
"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
post.published, post.partial, post.failed carry the post and one entry per account:
{
"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:
{
"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.
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
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.
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
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
- Any
2xxresponse 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,
failCountresets to0. - After 10 consecutive failures the webhook is disabled (
isActive: false). Re-enable it withPATCH /webhooks/{id}.
Best practices
- Return
2xxquickly, 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