Skip to content

Repository files navigation

FoPost Elixir SDK

Hex.pm Documentation CI License: MIT

The official Elixir SDK for the FoPost API. Connect social accounts once, then compose, schedule, and publish to every network FoPost supports from your own application.

Requires Elixir 1.15 or newer on OTP 25 or newer.

0.x release. The public API is still settling and minor versions may contain breaking changes. Pin an exact version if that matters to you.

Install

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

Nothing needs to go in your supervision tree: requests go out over Req, which pools connections through the Finch instance its own application starts.

Get an API key

Create one in the FoPost dashboard under Settings → API Keys. The full API reference lives at fopost.com/docs.

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)

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{}}:

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

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" — you never have to match on two shapes.

Predicates: unauthorized?/1, payment_required?/1, forbidden?/1, not_found?/1, validation?/1, rate_limited?/1, server_error?/1, transport_error?/1.

Every function also has a bang variant that returns the result or raises the same error:

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

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.

Scheduling

: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. Publishing answers when delivery is queued, not when the post is live — poll FoPost.Posts.deliveries/2 or subscribe a webhook for the outcome.

Pagination

FoPost.Posts.list/2 answers one page: rows on :data, counters on :meta.

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

stream/2 walks every page for you, lazily. Because a stream cannot answer with an error tuple, a failed page raises.

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

Media

{: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.

A direct upload sends the bytes to a presigned URL instead of through the API, then files them in the library:

{:ok, asset} =
  FoPost.Media.upload_direct(client, workspace.id, "chart.png", "image/png", bytes)

FoPost.Media.presign/2 and FoPost.Media.complete/2 are the two steps it wraps, for a client that PUTs the bytes itself.

Webhooks

A delivery carries X-FoPost-Signature (sha256=<hex>), X-FoPost-Event, and X-FoPost-Delivery. Verify against the raw body, before any JSON decoding:

{: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.

Retries

Every request is attempted up to three times: the original plus two retries. Only HTTP 429, HTTP 5xx, and transport failures are retried. Backoff is 500 ms doubling per attempt, capped at 60 seconds; a Retry-After header on a 429 wins, also capped at 60 seconds.

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

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_API_KEY and FOPOST_BASE_URL are read when nothing else supplies them.

:req_options is merged last into every request, so it wins over everything the SDK sets. Use it for a custom Finch pool, a proxy, or a test stub:

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

Anything the SDK does not wrap

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

The body comes back exactly as the API sent it, envelope included. Options are Req options, so :params, :json, and :form_multipart all work.

Resources

FoPost.Posts · FoPost.Workspaces · FoPost.Accounts · FoPost.AccountGroups · FoPost.Communities · FoPost.Labels · FoPost.Webhooks · FoPost.Analytics · FoPost.Automations · FoPost.Media · FoPost.Inbox · FoPost.Contacts · FoPost.Broadcasts · FoPost.Sequences · FoPost.Knowledge · FoPost.Ads · FoPost.Validate · FoPost.Activity FoPost.Automations · FoPost.Media · FoPost.Inbox · FoPost.Ads · FoPost.Validate · FoPost.GoogleBusiness

Inbox, contacts, broadcasts and ads

FoPost.Accounts.platform_metrics/2 reads the numbers only an account's own network reports, in its own vocabulary — ad-break earnings, story taps, a retention curve, the search terms behind a listing. A network whose metric access has not been granted yet answers 503:

{:ok, metrics} = FoPost.Accounts.platform_metrics(client, account.id)

for row <- metrics.account.metrics do
  IO.puts("#{row.label}: #{inspect(row.value)}")
end

Inbox and ads

FoPost.Inbox reads comments, mentions, and direct messages on connected accounts and replies as the account (scope inbox). FoPost.Contacts is the people behind that inbox: one row per human however many handles they write from, the custom fields the workspace keeps about them, and a CSV import (scope inbox, except conversation_analytics/2, which answers counts per thread under analytics).

FoPost.Broadcasts sends one message into every conversation the workspace already has with a segment of those contacts, and FoPost.Sequences walks a series of them on a delay. Neither opens a cold DM. Nothing is sent into a closed messaging window: Messenger and Instagram take a business-initiated message only within 24 hours of the contact's last one, so recipients outside it come back skipped with "window_closed" rather than attempted, and the number sent is often lower than the audience. Telegram, Slack, Bluesky and Reddit have no window. Both read under inbox; send/2, cancel/2, enroll/3 and unenroll/3 need publish as well.

{:ok, broadcast} =
  FoPost.Broadcasts.create(client,
    workspace_id: workspace_id,
    account_id: account_id,
    name: "September check-in",
    text: "New colours just landed. Want a look?",
    audience: %{"platforms" => ["instagram"]}
  )

# `recipients` is how many contacts matched, not how many will be messaged.
{:ok, sent} = FoPost.Broadcasts.send(client, broadcast.id)

# Who was skipped, and why.
{:ok, page} = FoPost.Broadcasts.recipients(client, broadcast.id, status: "skipped")
Enum.each(page.data, &IO.puts("#{&1.display_name} — #{&1.skip_reason}"))

{:ok, sequence} =
  FoPost.Sequences.create(client,
    workspace_id: workspace_id,
    account_id: account_id,
    name: "Welcome",
    steps: [
      %{"delay_hours" => 0, "text" => "Thanks for the follow"},
      %{"delay_hours" => 48, "text" => "Here is what people usually ask us first."}
    ]
  )

{:ok, _} = FoPost.Sequences.enroll(client, sequence.id, contact_ids: [contact_id])
{:ok, _} = FoPost.Sequences.unenroll(client, sequence.id, [contact_id])

FoPost.Knowledge holds what the workspace has told FoPost about itself — FAQs, notes, your own pages and plain-text files — and search/3 returns the passages that ground a drafted reply in your own answers rather than an invented one (same inbox scope). FoPost.Ads boosts posts, creates ads, and manages campaigns, ad sets, creatives, product catalogs, audiences, reach-and-frequency predictions, the public ad archive, ad account settings, insights, lead forms, and the leads feed (scope ads; boost/2, create/2, set_status/3, delete/3, bulk_set_status/2, and the campaign, ad set, and network ad writes spend money and also need publish). A boost, campaign, ad set, or ad starts paused unless paused: false. Campaign-tree objects are addressed by Meta id and read live, so those calls take :connection_id.

{:ok, page} = FoPost.Inbox.list(client, workspace_id: workspace.id, state: "unread")
{:ok, result} = FoPost.Inbox.reply(client, hd(page.data).id, text: "Thanks!")

{:ok, ad} =
  FoPost.Ads.boost(client,
    workspace_id: workspace.id,
    connection_id: connection.id,
    ad_account_id: "act_123",
    post_id: post.id,
    account_id: account.id,
    name: "Launch week",
    goal: "engagement",
    budget: %{minor: 5_000, type: "daily"},
    targeting: %{countries: ["US"], ageMin: 18, ageMax: 65, gender: "all"}
  )

meta = [workspace_id: workspace.id, connection_id: connection.id]
{:ok, tree} = FoPost.Ads.account_tree(client, "act_123", meta)
{:ok, _copy_id} = FoPost.Ads.duplicate_campaign(client, hd(tree.campaigns).id, meta)

{:ok, report} =
  FoPost.Ads.insights(client,
    connection_id: connection.id,
    object_id: hd(tree.campaigns).id,
    since: "2026-09-01",
    until: "2026-09-07",
    breakdown: "age",
    daily: true
  )

{:ok, page} = FoPost.Ads.leads_feed(client, workspace_id: workspace.id, limit: 50)
{:ok, next} = FoPost.Ads.leads_feed(client, workspace_id: workspace.id, cursor: page.next_cursor)

Google Business Profile

FoPost.GoogleBusiness manages a connected Business Profile location: the profile, attributes, food menus, services, photos, action links, verification and performance.

{:ok, location} = FoPost.GoogleBusiness.get_location(client, account_id)

{:ok, _} =
  FoPost.GoogleBusiness.update_location(client, account_id, %{"title" => "Corner Bakery"})

# Photos come from your media library, JPEG or PNG.
{:ok, _} = FoPost.GoogleBusiness.add_media(client, account_id, media_id: media_id,
                                            category: "INTERIOR")

{:ok, metrics} =
  FoPost.GoogleBusiness.get_performance(client, account_id,
    start_date: "2026-09-01",
    end_date: "2026-09-30"
  )

Responses relay Google's own shape as plain maps. Reads need the accounts scope, writes publish as well. Every call answers a 503 configuration_error until Google grants the deployment Business Profile API access.

Analytics

# How long a post keeps earning, from the repeated readings of each post
{:ok, decay} = FoPost.Analytics.decay(client, days: 30)
decay.half_life_bucket
#=> "1h_3h"

# Whether posting more earned more
{:ok, cadence} = FoPost.Analytics.frequency(client, days: 90)
cadence.best.label
#=> "3-5 a week"

# Every reading held for one post, with what moved between them
{:ok, timeline} = FoPost.Analytics.timeline(client, post.id)

# Mirror the metrics into your own store, without refetching everything
Stream.unfold(nil, fn
  :done ->
    nil

  cursor ->
    {:ok, page} = FoPost.Analytics.changes(client, since: cursor)
    next = if page.has_more and page.cursor, do: DateTime.to_iso8601(page.cursor), else: :done
    {page.changes, next}
end)
|> Enum.each(&save/1)

# Refresh one post now instead of waiting for the next collection run
{:ok, _} = FoPost.Analytics.collect_post(client, post.id)

# Posts on the account that never went out through FoPost
{:ok, page} = FoPost.Analytics.native_posts(client, account.id)

A post is addressed by its FoPost id or by its permalink, so a post made by hand on the network works the same way:

FoPost.Analytics.timeline(client, "https://x.com/acme/status/1")

Validating

FoPost.Validate checks content against platform rules without creating a post; nothing is stored (scope posts). post/2 checks a whole post, length/2 measures text the way each platform counts it, and media/2 fetches a public file and checks it.

{:ok, result} = FoPost.Validate.post(client, content: text, platforms: ["twitter", "linkedin"])
result.ready

{:ok, result} = FoPost.Validate.length(client, text: text, platforms: ["twitter"])
hd(result.platforms).limit

{:ok, result} = FoPost.Validate.media(client, url: "https://cdn.yourbrand.com/chart.png")
result.ok

Activity

FoPost.Activity.list/2 reads what happened in a workspace, newest first.

{:ok, page} = FoPost.Activity.list(client, workspace_id: workspace_id)
Enum.each(page.data, &IO.puts("#{&1.actor.name}: #{&1.summary}"))
page.next_cursor

kind: "security" is the audit log: members joining, leaving or changing role and access, and changes to two-step verification, passkeys, single sign-on and signed-in devices. Those rows are append-only and never expire.

{:ok, audit} = FoPost.Activity.list(client, workspace_id: workspace_id, kind: "security")

Examples

examples/create_post.exs creates a draft, preflights it, and publishes it.

Chatbots and the inbox

The chat adapter turns the FoPost inbox into one send/receive channel for a chatbot framework. It ships in the TypeScript and Python SDKs. There is no dedicated adapter here and no API change behind it, so the same loop is three pieces with this client:

  1. Verify the inbox.message_received webhook. The payload is ids only, on purpose, so nothing a customer wrote sits in your logs. The signing scheme is HMAC-SHA256 over {timestamp}.{body}, refused past a five minute tolerance.
  2. Read the item back with FoPost.Inbox.list(client, type: "dm", account_id: account_id), filtered to the payload's accountId and matched on its itemId.
  3. Answer with FoPost.Inbox.reply(client, item.id, text: text), or open a thread with FoPost.Inbox.start_conversation(client, …).

Reading needs the inbox scope; answering needs publish as well.

Development

mix deps.get
mix test                    # offline, against a local stub server
mix format
mix credo --strict
mix dialyzer

Tests never reach the real API.

Support

Questions and bug reports go to GitHub issues; anything else to fopost.com/contact.

License

MIT © Porter Bridge, LLC. See LICENSE.

Google Ads

Campaigns, ad groups, ads, audiences, and insights are on FoPost.Ads and dispatch by connection. What only Google has is in FoPost.GoogleAds:

{:ok, keywords} =
  FoPost.GoogleAds.keywords(client,
    connection_id: connection.id,
    customer_id: "1234567890"
  )

{:ok, id} =
  FoPost.GoogleAds.create_keyword(client,
    workspace_id: workspace.id,
    connection_id: connection.id,
    customer_id: "1234567890",
    ad_group_id: "1234567890~adGroup~77",
    text: "running shoes",
    match_type: "EXACT"
  )

Also keyword_ideas/2, keyword_metrics/2, search_terms/2, bid_strategies/2, ad_schedule/2 and set_ad_schedule/2, the negative keyword lists, assets/2 and asset_groups/2, local_services_leads/2, the conversion functions, and query/2 for a raw read-only GAQL SELECT. Changes need the publish scope as well as ads; :customer_id has to name an account the connection's grant reaches.

About

Official Elixir SDK for the FoPost API — schedule and publish social posts, manage accounts, media, and analytics. Built on Req.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages