# SDKs (/docs/sdks)



FeedHorizon has official SDKs for Node.js and Python. They are thin clients over the [REST API](/docs/api) and use the same names for every field. Every example on this page works the same as the matching cURL request in the API reference.

What the SDKs do for you:

* **Types:** requests and responses are typed, generated from [/openapi.json](/openapi.json).
* **Retries:** network errors, timeouts, `429` and `5xx` are retried with backoff, honouring `Retry-After`.
* **Idempotency:** every write gets an `Idempotency-Key`, the same on each attempt, so a retry never creates a second post.
* **Pagination:** one loop walks a list across all pages.
* **Errors:** one error class per status, carrying `code`, `param`, `details` and the request id.
* **Webhooks:** signature check and parsing in one call.

|                      | Package                                                              | Source                                                                          | Requires                                                        |
| -------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Node.js / TypeScript | [`@feedhorizon/sdk`](https://www.npmjs.com/package/@feedhorizon/sdk) | [bash-bash/feedhorizon-node](https://github.com/bash-bash/feedhorizon-node)     | Node.js 20+, also Deno, Bun and edge runtimes. No dependencies. |
| Python               | [`feedhorizon`](https://pypi.org/project/feedhorizon/)               | [bash-bash/feedhorizon-python](https://github.com/bash-bash/feedhorizon-python) | Python 3.10+, depends on `httpx`                                |

Both are MIT licensed.

Install [#install]

<CodeBlockTabs defaultValue="Node.js">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js">
    ```bash
    npm install @feedhorizon/sdk
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```bash
    pip install feedhorizon
    ```
  </CodeBlockTab>
</CodeBlockTabs>

Create a client [#create-a-client]

Create an API key under [Dashboard → API & Apps](/dashboard/api-keys) and set it as the environment variable `FEEDHORIZON_API_KEY`. The client reads it from there.

<CodeBlockTabs defaultValue="Node.js">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js">
    ```ts
    import FeedHorizon from '@feedhorizon/sdk';

    const client = new FeedHorizon(); // reads FEEDHORIZON_API_KEY
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    from feedhorizon import FeedHorizon

    client = FeedHorizon()  # reads FEEDHORIZON_API_KEY
    ```
  </CodeBlockTab>
</CodeBlockTabs>

Pass the key as `apiKey` (Node.js) or `api_key` (Python) to set it in code instead. Never commit a key.

Create a post [#create-a-post]

<CodeBlockTabs defaultValue="Node.js">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js">
    ```ts
    const { data: profiles } = await client.profiles.list();

    const post = await client.posts.create({
        profileId: profiles[0].id,
        content: 'Hello from the FeedHorizon SDK!',
        platforms: ['linkedin', 'bluesky'],
        scheduledFor: '2026-11-12T09:00:00+01:00',
    });

    console.log(post.id, post.status); // post_… scheduled
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    profile = client.profiles.list().data[0]

    post = client.posts.create(
        profileId=profile["id"],
        content="Hello from the FeedHorizon SDK!",
        platforms=["linkedin", "bluesky"],
        scheduledFor="2026-11-12T09:00:00+01:00",
    )

    print(post["id"], post["status"])  # post_… scheduled
    ```
  </CodeBlockTab>
</CodeBlockTabs>

Methods return the object inside `data` directly. Leave out the timing field for a draft, or send `addToQueue` or `publishNow` instead of `scheduledFor` (see [Scheduling](/docs/guides/scheduling)). In Python, responses are plain dicts with type hints, and parameters keep the API's names (`profileId`, not `profile_id`).

Lists and pagination [#lists-and-pagination]

A list call returns the first page. Loop over it to walk every item across all pages; the SDK fetches the next page when it needs it.

<CodeBlockTabs defaultValue="Node.js">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js">
    ```ts
    const page = await client.posts.list({ status: 'failed', limit: 50 });
    console.log(page.data.length, page.hasNextPage());

    for await (const post of client.posts.list({ updatedSince: '2026-11-01T00:00:00Z' })) {
        console.log(post.id, post.status);
    }
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    page = client.posts.list(status="failed", limit=50)
    print(len(page.data), page.has_next_page())

    for post in client.posts.list(updatedSince="2026-11-01T00:00:00Z"):
        print(post["id"], post["status"])
    ```
  </CodeBlockTab>
</CodeBlockTabs>

`from` is a keyword in Python, so the calendar filter of `posts.list` is `from_` there.

Errors [#errors]

Every API error is an `APIError` with `status`, `code`, `message`, `param`, `details` and the request id. Branch on `code`; [Errors](/docs/api/errors) lists them all.

<CodeBlockTabs defaultValue="Node.js">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js">
    ```ts
    import { ConflictError, InvalidRequestError } from '@feedhorizon/sdk';

    try {
        await client.posts.update(postId, { content: 'New text' });
    } catch (error) {
        if (error instanceof InvalidRequestError) {
            for (const d of error.details) console.log(d.code, d.param, d.message);
        } else if (error instanceof ConflictError && error.code === 'post_not_editable') {
            // already publishing or published
        } else {
            throw error;
        }
    }
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    from feedhorizon import ConflictError, InvalidRequestError

    try:
        client.posts.update(post_id, content="New text")
    except InvalidRequestError as e:
        for d in e.details:
            print(d["code"], d.get("param"), d["message"])
    except ConflictError as e:
        if e.code == "post_not_editable":
            ...  # already publishing or published
    ```
  </CodeBlockTab>
</CodeBlockTabs>

| Class                                   | Status                                                        |
| --------------------------------------- | ------------------------------------------------------------- |
| `InvalidRequestError`                   | 400, 422                                                      |
| `AuthenticationError`                   | 401                                                           |
| `PermissionDeniedError`                 | 403                                                           |
| `NotFoundError`                         | 404                                                           |
| `ConflictError`                         | 409                                                           |
| `RateLimitError`                        | 429, with the wait in seconds as `retryAfter` / `retry_after` |
| `InternalServerError`                   | 5xx                                                           |
| `APIConnectionError`, `APITimeoutError` | No answer from the server                                     |

Include the request id (`requestId` / `request_id`) when you contact support.

Retries and idempotency [#retries-and-idempotency]

Failed requests are retried twice by default. Pass your own idempotency key to make a write safe to repeat across process restarts, for example from a job queue:

<CodeBlockTabs defaultValue="Node.js">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js">
    ```ts
    await client.posts.create(params, { idempotencyKey: `launch-${campaignId}` });

    // per client or per request
    const patient = new FeedHorizon({ maxRetries: 5, timeout: 30_000 });
    await client.posts.get(postId, { maxRetries: 0, timeout: 5_000 });
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    client.posts.create(**params, idempotency_key=f"launch-{campaign_id}")

    # per client or per request
    patient = FeedHorizon(max_retries=5, timeout=30)
    client.posts.get(post_id, max_retries=0, timeout=5)
    ```
  </CodeBlockTab>
</CodeBlockTabs>

See [Idempotency](/docs/api/idempotency) for how the server treats repeated keys.

Media [#media]

<CodeBlockTabs defaultValue="Node.js">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js">
    ```ts
    // From a public URL
    const image = await client.media.import('https://example.com/launch.jpg');

    // From a file (up to 5 GB)
    import { readFile } from 'node:fs/promises';
    const video = await client.media.upload({ file: await readFile('teaser.mp4'), filename: 'teaser.mp4', mimeType: 'video/mp4' });

    await client.posts.create({ profileId, content: 'The teaser is out', platforms: ['instagram'], mediaIds: [image.id, video.id], addToQueue: true });
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    # From a public URL
    image = client.media.import_url("https://example.com/launch.jpg")

    # From a file (up to 5 GB)
    with open("teaser.mp4", "rb") as f:
        video = client.media.upload(f, filename="teaser.mp4", mime_type="video/mp4")

    client.posts.create(profileId=profile_id, content="The teaser is out", platforms=["instagram"], mediaIds=[image["id"], video["id"]], addToQueue=True)
    ```
  </CodeBlockTab>
</CodeBlockTabs>

`upload` announces the file and uploads the bytes in one call. See [Media uploads](/docs/guides/media-uploads).

Webhooks [#webhooks]

Verify the signature against the **raw** request body, then handle the event. In TypeScript, `event.data` is typed by `event.event`.

<CodeBlockTabs defaultValue="Node.js">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js">
    ```ts
    import express from 'express';
    import { constructWebhookEvent, WebhookVerificationError } from '@feedhorizon/sdk';

    app.post('/webhooks/feedhorizon', express.raw({ type: 'application/json' }), async (req, res) => {
        let event;
        try {
            event = await constructWebhookEvent({
                payload: req.body,
                signature: req.get('X-Webhook-Signature'),
                secret: process.env.FEEDHORIZON_WEBHOOK_SECRET!,
            });
        } catch (error) {
            if (error instanceof WebhookVerificationError) return res.sendStatus(400);
            throw error;
        }
        res.sendStatus(200);

        if (event.event === 'post.target.failed') {
            console.log(event.data.platform, event.data.error?.code);
        }
    });
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    import os

    from flask import Flask, request
    from feedhorizon import WebhookVerificationError, construct_webhook_event

    app = Flask(__name__)


    @app.post("/webhooks/feedhorizon")
    def feedhorizon_webhook():
        try:
            event = construct_webhook_event(
                request.get_data(), request.headers.get("X-Webhook-Signature"), os.environ["FEEDHORIZON_WEBHOOK_SECRET"]
            )
        except WebhookVerificationError:
            return "", 400

        if event["event"] == "post.target.failed":
            print(event["data"]["platform"], event["data"]["error"])
        return "", 200
    ```
  </CodeBlockTab>
</CodeBlockTabs>

Events older than five minutes are rejected, which stops replays. See [Webhooks](/docs/guides/webhooks) for every event.

Async Python [#async-python]

`AsyncFeedHorizon` has the same methods as `FeedHorizon`, awaited:

```python
import asyncio
from feedhorizon import AsyncFeedHorizon


async def main() -> None:
    async with AsyncFeedHorizon() as client:
        post = await client.posts.get("post_…")
        async for failed in client.posts.list(status="failed"):
            print(failed["id"])


asyncio.run(main())
```

Resources [#resources]

| Node.js                  | Python                    | Methods                                                                                            |
| ------------------------ | ------------------------- | -------------------------------------------------------------------------------------------------- |
| `client.me()`            | `client.me()`             | Organization, plan, rate limit and scopes of the key                                               |
| `client.posts`           | `client.posts`            | `list`, `get`, `create`, `validate`, `update`, `delete`, `publish`, `cancel`, `retry`, `duplicate` |
| `client.media`           | `client.media`            | `list`, `get`, `create`, `import` (Python: `import_url`), `upload`, `delete`                       |
| `client.profiles`        | `client.profiles`         | `list`, `get`, `create`, `update`, `delete`                                                        |
| `client.profiles.queue`  | `client.profiles.queue`   | `get`, `set`, `slots`                                                                              |
| `client.accounts`        | `client.accounts`         | `list`, `get`, `delete`, `boards`, `tiktokCreator` (Python: `tiktok_creator`)                      |
| `client.platforms`       | `client.platforms`        | `list`, `get`                                                                                      |
| `client.platformInvites` | `client.platform_invites` | `list`, `create`, `delete` ([connect links](/docs/guides/connect-links))                           |
| `client.webhooks`        | `client.webhooks`         | `list`, `get`, `create`, `update`, `delete`, `test`                                                |
| `client.apiKeys`         | `client.api_keys`         | `list`, `create`, `delete`                                                                         |

Configuration [#configuration]

| Node.js          | Python            | Default                                                       |
| ---------------- | ----------------- | ------------------------------------------------------------- |
| `apiKey`         | `api_key`         | `FEEDHORIZON_API_KEY`                                         |
| `baseURL`        | `base_url`        | `FEEDHORIZON_BASE_URL`, else `https://feedhorizon.com/api/v1` |
| `timeout`        | `timeout`         | 60 seconds (`60000` ms in Node.js)                            |
| `maxRetries`     | `max_retries`     | `2`                                                           |
| `defaultHeaders` | `default_headers` | none                                                          |
| `fetch`          | `http_client`     | global `fetch` / a new `httpx` client                         |

Other languages [#other-languages]

Every endpoint, field and webhook payload is in [/openapi.json](/openapi.json) (OpenAPI 3.1). Use it with any OpenAPI client generator, or call the API with plain HTTP as the cURL examples show.

Versions [#versions]

The SDKs follow [semantic versioning](https://semver.org). New API fields and endpoints arrive in minor releases; see [Versioning](/docs/api/versioning) for what the API itself promises. Release notes are in each repository's CHANGELOG and GitHub releases.
