Docs

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

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.

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:

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.

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 old

Without 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
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