Webhooks
Subscribe to FeedHorizon events and verify deliveries securely.
Diese Seite gibt es noch nicht auf Deutsch, deshalb siehst du sie auf Englisch.
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.
The full payload of every event, with each field and its type, is in the API reference under Webhook events. It is generated from the code that sends the events, and the same schemas are in the webhooks section of /openapi.json.
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.
The SDKs do all of this and return the parsed event:
import { constructWebhookEvent } from '@feedhorizon/sdk';
// rawBody: the request body as received (string or bytes)
// signatureHeader: the X-Webhook-Signature header
const event = await constructWebhookEvent({
payload: rawBody,
signature: signatureHeader,
secret: process.env.FEEDHORIZON_WEBHOOK_SECRET!,
}); // throws WebhookVerificationError if the signature is wrong or too oldWithout an SDK:
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