Docs

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

EventSent when
post.scheduledA post was scheduled
post.publishedAll targets of a post were published
post.failedAll targets of a post failed
post.partialSome targets were published, others failed
post.updatedContent, platforms or time of a not yet published post changed. data.changes lists each changed field with from and to
post.deletedA post was deleted
post.target.publishedOne account of a post was published, without waiting for the post's other accounts
post.target.failedOne account of a post failed for good (after any automatic retries). data.error says why and who can fix it
account.connectedA social account was connected
account.disconnectedA social account was disconnected
account.expiringA 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_reauthThe 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
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:

HeaderContent
X-Webhook-Signaturet=<unix seconds>,v1=<hex signature>
X-Webhook-EventThe event name
X-Webhook-IdThe event id, same as id in the body
X-Webhook-DeliveryThe 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.

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

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

  • 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

  • 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