Elixir SDK

The official Elixir client for the FoPost API, built on Req.

fopost is the official Elixir package. It needs Elixir 1.15 or newer on OTP 25 or newer, and requests go out over Req, which pools connections through the Finch instance its own application starts, so nothing has to go in your supervision tree.

def deps do
  [{:fopost, "~> 0.1"}]
end

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.

The package is not on Hex yet: the first release has not been cut. Until it lands, depend on the repository directly: {:fopost, github: "fopost/fopost-elixir"}.

Published on Hex. Source and issues: github.com/fopost/fopost-elixir. MIT licensed.

Quick start

client = FoPost.new(api_key: "fp_...")   # or set FOPOST_API_KEY

{:ok, [workspace | _]} = FoPost.Workspaces.list(client)
{:ok, accounts} = FoPost.Accounts.list(client, workspace_id: workspace.id)

{:ok, post} =
  FoPost.Posts.create(client,
    workspace_id: workspace.id,
    content: "Hello from Elixir",
    accounts: Enum.map(accounts, & &1.id)
  )

{:ok, result} = FoPost.Posts.publish(client, post.id)

Create a key at Settings → API Keys in the dashboard. It travels as the X-API-Key header, never as a bearer token.

The client is a plain struct with no process behind it, so build it once and pass it around, including across processes.

Results and errors

Every function answers {:ok, result} or {:error, %FoPost.Error{}}, and every one has a bang twin that returns the result or raises the same error:

post = FoPost.Posts.create!(client, workspace_id: workspace.id, content: "Hello")

One struct covers every failure. :status is the HTTP status, :code the API's machine-readable error, :message its explanation, and :body the decoded body exactly as it arrived, so a field the SDK does not model yet is still reachable. A connection that never produced a response is the same struct with status: nil and code: "transport_error", so you never have to match on two shapes.

case FoPost.Posts.publish(client, post.id) do
  {:ok, result} ->
    Logger.info("queued: #{result.post_status}")

  {:error, %FoPost.Error{} = error} ->
    cond do
      FoPost.Error.rate_limited?(error) -> retry_in(error.retry_after)
      FoPost.Error.payment_required?(error) -> send_to(error.upgrade_url)
      FoPost.Error.validation?(error) -> report(error.body)
      true -> Logger.error(Exception.message(error))
    end
end

Elixir does not need one exception module per status code, so there is one struct and a predicate for each case callers actually branch on.

PredicateTrue for
validation?/1400, 422
unauthorized?/1401
payment_required?/1402, and :upgrade_url carries where to send the user
forbidden?/1403
not_found?/1404
rate_limited?/1429, and :retry_after holds the wait in seconds
server_error?/1any 5xx
transport_error?/1the request never produced a response

The struct is also an exception, so raise error and the bang twins work the way they do elsewhere in Elixir.

Composing

:content takes a string for a single block, or a list for a thread. A block may also be a map of text plus media:

FoPost.Posts.create(client,
  workspace_id: workspace.id,
  accounts: [account.id],
  content: [
    "First post in the thread",
    %{text: "Second one, with an image", media: [%{type: "image", url: url}]}
  ]
)

:accounts takes account ids, FoPost.Account structs, or maps carrying an id. A media item is a map of type ("image", "video", or "gif"), name, and url.

FoPost.Posts.create/2 normalises all of those shapes for you, so reach for FoPost.Content only when you want to build the blocks yourself:

FoPost.Content.text("One post")
FoPost.Content.thread(["First", "Second", "Third"])

Scheduling and publishing

:status is "draft" or "scheduled"; a scheduled post needs :schedule_at, which accepts a DateTime, a NaiveDateTime, or an ISO 8601 string.

FoPost.Posts.create(client,
  workspace_id: workspace.id,
  accounts: [account.id],
  status: "scheduled",
  schedule_at: ~U[2026-09-01 10:00:00Z],
  content: "Scheduled with the SDK"
)

To send something out now, create it and call publish/3. Nothing reaches a platform without that call or a schedule the user set, and publishing answers when delivery is queued, not when the post is live. Poll FoPost.Posts.deliveries/2 or subscribe a webhook for the outcome.

publish/3 takes :account_ids to reach a subset of the post's accounts, and :dry_run to validate everything without sending anything. preflight/2 checks a post against every platform it targets, without publishing:

{:ok, check} = FoPost.Posts.preflight(client, post.id)

for account <- check.accounts, account.ready != true do
  IO.puts("#{account.platform}: #{inspect(account.issues)}")
end

Afterwards, deliveries/2 is the per-account state, retry/3 re-sends only what failed, and cancel/3 stops what has not gone out yet.

Pagination

FoPost.Posts.list/2 answers one page: rows on :data, counters on :meta (current_page, per_page, total, last_page, from, to).

{:ok, page} = FoPost.Posts.list(client, workspace_id: workspace.id, per_page: 50)
IO.puts("#{page.meta.total} posts")

stream/2 takes the same filters and walks every page for you, lazily, so Enum.take/2 stops fetching. Because a stream cannot answer with an error tuple, a failed page raises FoPost.Error.

client
|> FoPost.Posts.stream(workspace_id: workspace.id, status: "published")
|> Stream.map(& &1.id)
|> Enum.take(100)

Media

Up to five files per call, 50 MB each, counted against the plan's storage allowance and reachable with the posts scope. Upload first, then attach the returned asset to a block:

{:ok, [asset]} =
  FoPost.Media.upload(client, workspace_id: workspace.id, files: ["chart.png"])

FoPost.Posts.create(client,
  workspace_id: workspace.id,
  accounts: [account.id],
  content: %{text: "The numbers", media: [FoPost.MediaAsset.to_media_item(asset)]}
)

A file is a path, a {filename, content} tuple, or a map of :filename, :content, and optionally :content_type.

Resources

ModuleFunctions
FoPost.Postslist/2, stream/2, get/2, create/2, update/3, delete/2, duplicate/2, publish/3, cancel/3, retry/3, preflight/2, deliveries/2, publish_runs/2, analytics/2, bulk_shift/2, bulk_label/2, bulk_delete/2, validate_bulk_import/2, commit_bulk_import/2, rollback_bulk_import/2
FoPost.Workspaceslist/1, get/2, create/2, update/3, delete/2, analytics/2
FoPost.Accountslist/2, get/2, create/2, delete/2, set_primary/2, validate/2, health/3, health_summary/2, refresh_token/2, analytics/3
FoPost.Communitieslist/2, sync/2, search/3, add/3, remove/3
FoPost.Labelslist/2, get/2, create/2, update/3, delete/2
FoPost.Webhooksevents/0, list/1, create/2, update/3, delete/2, test/2, plus the signature helpers below
FoPost.Analyticsoverview/2, time_series/2, top_posts/2, labels/2, posts_table/2, demographics/2, posting_streak/2, collect/2
FoPost.Automationslist/1, get/2, create/2, update/3, delete/2, toggle/2, runs/3, run/3, trigger/3, stats/1
FoPost.Medialist/2, upload/2, delete/2

Each of these has a bang twin that returns the result or raises the same error. stream/2 is the exception: it has none, because it already raises on a failed page. Responses come back as structs with snake_case fields, each keeping the decoded body on :raw, so a field added server side stays reachable from an older client.

Every FoPost.Analytics read takes the same window and scope options: :workspace_id, :account_id, :days or :from and :to, plus :limit, :page, :sort, and :label where the endpoint ranks or paginates. Anything you leave out keeps the API's own default:

{:ok, overview} = FoPost.Analytics.overview(client, workspace_id: workspace.id, days: 30)
overview.total_followers

Configuration

Explicit options beat application config, which beats the environment.

config :fopost,
  api_key: System.get_env("FOPOST_API_KEY"),
  base_url: "https://api.fopost.com/v1",
  timeout: 30_000,
  max_retries: 2
FoPost.new(
  api_key: "fp_...",                       # or config :fopost, or FOPOST_API_KEY
  base_url: "https://api.fopost.com/v1",   # or config, or FOPOST_BASE_URL
  timeout: 30_000,                         # milliseconds, per request
  max_retries: 2,                          # retries after the first attempt
  user_agent: "acme/2.0",
  req_options: [finch: MyApp.Finch]        # merged last, so it wins over everything above
)

FOPOST_API_KEY and FOPOST_BASE_URL are read when nothing else supplies them. Without a key anywhere, FoPost.new/1 raises ArgumentError at build time rather than failing on the first call.

:req_options is the escape hatch for a custom Finch pool, a proxy, custom TLS, or a test stub. It is merged last, so it overrides even the retry policy:

FoPost.new(api_key: key, req_options: [finch: MyApp.Finch, connect_options: [timeout: 5_000]])

Retries

Every request is attempted up to three times: the original plus two retries. Only 429, 5xx, and transport failures are retried; nothing else is, because the request itself is what needs changing. Backoff is 500 ms doubling per attempt, capped at 60 seconds, and a Retry-After header on a 429 wins, capped the same way.

FoPost.new(api_key: key, max_retries: 0)   # off
FoPost.new(api_key: key, max_retries: 4)   # five attempts

Verifying webhooks

A delivery carries X-FoPost-Signature (sha256=<hex>, the HMAC-SHA256 of the raw request body keyed with the subscription's secret), X-FoPost-Event, and X-FoPost-Delivery. Verify against the raw body, before any JSON decoding, or the bytes will not match:

{:ok, raw, conn} = Plug.Conn.read_body(conn)
[signature] = Plug.Conn.get_req_header(conn, "x-fopost-signature")

case FoPost.Webhooks.verify_and_parse(raw, signature, secret) do
  {:ok, event} -> handle(event)
  {:error, :invalid_signature} -> Plug.Conn.send_resp(conn, 401, "")
  {:error, :invalid_payload} -> Plug.Conn.send_resp(conn, 400, "")
end

The comparison is constant time. No timestamp is mixed into the signature, so there is no replay window to enforce. Deduplicate on X-FoPost-Delivery if you need it.

verify_signature/3 and parse_event/1 are the two halves on their own, and signature/2 produces the header value FoPost would send, which is what you want when testing your own handler. FoPost.Webhooks.events/0 lists every event a subscription can ask for.

The signing secret comes back from create/2 on :secret, once and only there. Store it then.

{:ok, hook} =
  FoPost.Webhooks.create(client,
    workspace_id: workspace.id,
    url: "https://example.com/hooks/fopost",
    events: ["post.published", "delivery.failed"]
  )

Anything the SDK does not wrap

{:ok, body} = FoPost.request(client, :get, "/platforms")
{:ok, body} = FoPost.request(client, :get, "/posts", params: [per_page: 5])
{:ok, body} = FoPost.request(client, :post, "/posts/#{id}/publish", json: %{})

The body comes back exactly as the API sent it, envelope included; pass unwrap: true to peel a {"data": ...} wrapper off it. Options are Req options, so :params, :json, and :form_multipart all work. request!/4 is the bang twin.

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