Django

The FoPost Python client wired into Django, with settings, management commands, and signals.

fopost-django is a thin wrapper over the Python SDK. It adds Django settings, a lazily built shared client, two management commands, system checks, and a signed webhook receiver that fires Django signals. There are no models and no migrations, because this package stores nothing.

Needs Python 3.10 or newer and Django 4.2, 5.0, 5.1, or 5.2.

pip install fopost-django

This is a 0.x release. The surface is still settling and a minor version may break something. Pin an exact version if that matters to you.

Published on PyPI. Source and issues: github.com/fopost/fopost-django. MIT licensed.

Settings

Add the app and one settings dict:

import os

INSTALLED_APPS = [
    # ...
    "fopost_django",
]

FOPOST = {
    "API_KEY": os.environ["FOPOST_API_KEY"],
    "WEBHOOK_SECRET": os.environ["FOPOST_WEBHOOK_SECRET"],
    "DEFAULT_WORKSPACE_ID": os.environ.get("FOPOST_WORKSPACE_ID"),
}
KeyDefaultEnv fallbackWhat it does
API_KEYnone, requiredFOPOST_API_KEYYour API key
BASE_URLthe hosted APIFOPOST_BASE_URLAPI root
TIMEOUT30.0(none)Seconds to wait for one request
MAX_RETRIES3(none)Attempts for a rate limited request
DEFAULT_WORKSPACE_IDNoneFOPOST_WORKSPACE_IDWorkspace the management commands use when --workspace is left out
WEBHOOK_SECRETNoneFOPOST_WEBHOOK_SECRETSecret the webhook receiver verifies signatures against
HTTP_CLIENTNone(none)Advanced: an httpx.Client to send through, for a proxy or a test transport

The whole dict is optional as long as FOPOST_API_KEY is in the environment. Django refuses to start without a key (an ImproperlyConfigured at boot beats a 401 in a customer's request), and manage.py check warns about the softer misconfigurations: a webhook URL wired up with no secret, a plaintext base URL, an API key hardcoded into settings.

Quick start

from django.http import JsonResponse
from fopost_django import client


def announce(request):
    workspace = client.workspaces.list()[0]
    accounts = client.accounts.list(workspace_id=workspace.id)

    post = client.posts.create(
        workspace_id=workspace.id,
        content="Shipping today: scheduled posting straight from Django.",
        accounts=[a.id for a in accounts],
    )
    client.posts.publish(post.id)

    return JsonResponse({"post_id": post.id})

client is a lazy proxy, so importing it at module scope never touches settings. Prefer an explicit call? get_client() returns the same memoized instance:

from fopost_django import get_client

get_client().posts.list(workspace_id=workspace_id, status="scheduled")

The client is built once per process, on first use, behind a lock, and rebuilt automatically if settings.FOPOST changes, which is what override_settings does in your tests.

Management commands

python manage.py fopost_accounts --workspace 9b2f6c1e-...
python manage.py fopost_accounts --json

Lists the social accounts connected to a workspace. Falls back to FOPOST["DEFAULT_WORKSPACE_ID"], and to every workspace the key reaches when neither is set.

# A draft
python manage.py fopost_post -a acc_1 -a acc_2 --text "Hello from Django"

# Scheduled
python manage.py fopost_post -a acc_1 --text "Later" --schedule-at 2026-09-01T10:00:00Z

# Out the door now
python manage.py fopost_post -a acc_1 --text "Now" --publish
FlagWhat it does
-w, --workspaceWorkspace id. Defaults to FOPOST["DEFAULT_WORKSPACE_ID"]
-a, --accountA connected account. Repeat for more than one. At least one is required
-t, --textThe post body. Required
--titleTitle, for platforms that use one
--labelA label id to attach. Repeat for more than one
--schedule-atISO 8601 datetime. A naive value is read in the project's current timezone
--publishQueue the post for delivery straight after creating it

--schedule-at and --publish are mutually exclusive. API failures come back as ordinary CommandError output, not a traceback.

Receiving webhooks

Add the URLs:

from django.urls import include, path

urlpatterns = [
    path("fopost/", include("fopost_django.urls")),
]

That serves the receiver at /fopost/webhook/, reversible as reverse("fopost:webhook"). Register that URL as a webhook in the dashboard, copy the secret it shows you into FOPOST["WEBHOOK_SECRET"], and connect a receiver:

from django.dispatch import receiver
from fopost_django.signals import post_published, post_failed


@receiver(post_published)
def on_published(sender, event, data, payload, request, delivery_id, **kwargs):
    Article.objects.filter(fopost_post_id=data["postId"]).update(announced=True)


@receiver(post_failed)
def on_failed(sender, data, **kwargs):
    logger.error("FoPost post %s failed", data.get("postId"))

Connect them from your app config's ready(), the usual way.

SignalFoPost event
post_publishedpost.published
post_failedpost.failed
post_partially_failedpost.partially_failed
delivery_publisheddelivery.published
delivery_faileddelivery.failed
delivery_delayeddelivery.delayed
account_health_changedaccount.health_changed
webhook_receivedevery verified delivery, whatever the event

Every receiver gets the same keyword arguments: event, data, payload (the whole envelope, with its timestamp), request, and delivery_id, the X-FoPost-Delivery header, which stays the same across retries and so makes a good idempotency key.

FoPost signs the raw request body with HMAC-SHA256, keyed on the webhook secret, and sends the hex digest as X-FoPost-Signature: sha256=<digest>. The view recomputes it over request.body and compares with hmac.compare_digest. A missing, malformed, or wrong signature is a 403 before any signal fires; a body that is not a JSON object is a 400. The view is csrf_exempt and accepts POST only.

Failures are meant to propagate. If a receiver raises, the response is a 5xx and FoPost retries the delivery with backoff. Keep receivers quick and idempotent, or hand the work to a task queue.

Testing your own code

Point the SDK at a stub transport instead of the network:

import httpx
from django.test import override_settings
from fopost_django import reset_client


def handler(request):
    return httpx.Response(200, json={"data": {"id": "post_1", "status": "draft"}})


with override_settings(
    FOPOST={
        "API_KEY": "fp_test",
        "HTTP_CLIENT": httpx.Client(transport=httpx.MockTransport(handler)),
    }
):
    reset_client()
    ...

override_settings already invalidates the cached client; reset_client() is there for the cases where you swap the transport by hand.

The rest of the API

Posts, accounts, workspaces, labels, AI, pagination, error classes, and retry behaviour all live in the parent SDK, and everything the Python SDK page documents works through fopost_django.client unchanged.

from fopost import FopostError, RateLimitError

try:
    client.posts.publish(post_id)
except RateLimitError as exc:
    retry_in = exc.retry_after
except FopostError as exc:
    print(exc.status, exc.code, exc.message)

Next

Related documentation
  • SDKs & Integrations

    Official FoPost clients for TypeScript, Python, PHP, Ruby, Go, Rust, Java, .NET, Swift, Kotlin, Dart, and Elixir, framework integrations from Laravel to Next.js, and tooling for the CLI, CI, Terraform, and the automation platforms.

  • SDKs Overview

    Every official FoPost client, what it covers, and how to pick one.

  • TypeScript SDK

    The official TypeScript and Node.js client for the FoPost API.

  • Python SDK

    The official Python client for the FoPost API.

  • PHP SDK

    The official PHP client for the FoPost API, with no framework and no HTTP library.

  • Ruby SDK

    The official Ruby client for the FoPost API, with no runtime dependencies.

  • Go SDK

    The official Go client for the FoPost API.

  • Rust SDK

    The official Rust client for the FoPost API, async and built on reqwest.

Was this helpful?

On this page