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"}]
endThis 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
endElixir does not need one exception module per status code, so there is one struct and a predicate for each case callers actually branch on.
| Predicate | True for |
|---|---|
validation?/1 | 400, 422 |
unauthorized?/1 | 401 |
payment_required?/1 | 402, and :upgrade_url carries where to send the user |
forbidden?/1 | 403 |
not_found?/1 | 404 |
rate_limited?/1 | 429, and :retry_after holds the wait in seconds |
server_error?/1 | any 5xx |
transport_error?/1 | the 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)}")
endAfterwards, 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
| Module | Functions |
|---|---|
FoPost.Posts | list/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.Workspaces | list/1, get/2, create/2, update/3, delete/2, analytics/2 |
FoPost.Accounts | list/2, get/2, create/2, delete/2, set_primary/2, validate/2, health/3, health_summary/2, refresh_token/2, analytics/3 |
FoPost.Communities | list/2, sync/2, search/3, add/3, remove/3 |
FoPost.Labels | list/2, get/2, create/2, update/3, delete/2 |
FoPost.Webhooks | events/0, list/1, create/2, update/3, delete/2, test/2, plus the signature helpers below |
FoPost.Analytics | overview/2, time_series/2, top_posts/2, labels/2, posts_table/2, demographics/2, posting_streak/2, collect/2 |
FoPost.Automations | list/1, get/2, create/2, update/3, delete/2, toggle/2, runs/3, run/3, trigger/3, stats/1 |
FoPost.Media | list/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_followersConfiguration
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: 2FoPost.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 attemptsVerifying 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, "")
endThe 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.