Packages

Official Elixir SDK for the FoPost API: schedule, publish, and analyze social media content across every connected platform.

Current section

Files

Jump to
fopost README.md
Raw

README.md

# FoPost Elixir SDK

[![Hex.pm](https://img.shields.io/hexpm/v/fopost.svg)](https://hex.pm/packages/fopost)
[![Documentation](https://img.shields.io/badge/hexdocs-fopost-8e5ea2.svg)](https://hexdocs.pm/fopost)
[![CI](https://img.shields.io/github/actions/workflow/status/fopost/fopost-elixir/ci.yml?branch=main&label=ci)](https://github.com/fopost/fopost-elixir/actions)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

The official Elixir SDK for the [FoPost](https://fopost.com) 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

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

Nothing needs to go in your supervision tree: requests go out over
[Req](https://hexdocs.pm/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](https://fopost.com/docs).

## Quick start

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

```elixir
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:

```elixir
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`:

```elixir
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.

```elixir
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`.

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

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

## Media

```elixir
{: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 post can take its files directly. They are stored in the workspace library and attached
to the first content block:

```elixir
FoPost.Posts.create(client,
  workspace_id: workspace.id,
  accounts: [account.id],
  content: "Watch this",
  files: ["clip.mp4", %{filename: "chart.png", mime_type: "image/png", data: bytes}]
)

FoPost.Posts.update(client, post.id, files: ["more.png"])
```

Files within the account's multipart ceiling travel with the post in one request; larger
ones are direct-uploaded first and attached by URL. The SDK picks the route, so the result
is the same either way.

### Limits

How large a file the account may upload grows with its connected accounts, or with the
Large Uploads add-on. `FoPost.Media.limits/2` reports the current ceilings:

```elixir
{:ok, limits} = FoPost.Media.limits(client, workspace.id)
limits.max_file_bytes            # the largest file by any route
limits.multipart_max_file_bytes  # the most one file may carry in a multipart request
limits.next_tier                 # %{up_to_accounts: 7, max_file_bytes: 419_430_400} or nil
```

A file over the limit is refused with a `413` whose body carries the ceiling:

```elixir
{:error, %FoPost.Error{status: 413, code: "file_too_large"} = error} = result
error.body["max_file_bytes"]
error.upgrade_url
```

A direct upload sends the bytes to a presigned URL instead of through the API, then files
them in the library. Pass `{:file, path}` to stream the file from disk:

```elixir
{:ok, asset} =
  FoPost.Media.upload_direct(client, workspace.id, "clip.mp4", "video/mp4", {:file, path})
```

`FoPost.Media.presign/2` and `FoPost.Media.complete/2` are the two steps it wraps, for a
client that `PUT`s the bytes itself. `presign/2` also takes `:folder_id`, `:origin`,
`:brand_role`, `:brand_voice_id`, and `:person_consent`.

## Webhooks

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

```elixir
{:ok, raw, conn} = Plug.Conn.read_body(conn)

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

`verify/4` prefers the timestamped signature and rejects one more than 300 seconds old
(`:tolerance` changes that); it falls back to the plain body signature only when no
timestamp header arrived. Every comparison is constant time.

## 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.

```elixir
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.

```elixir
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:

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

## Anything the SDK does not wrap

```elixir
{: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.Blog` · `FoPost.Blogs` · `FoPost.Whatsapp`
`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`:

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

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

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

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

## Blog

`FoPost.Blog` plans keywords, writes articles and publishes them to a target: the product
blog, a connected account, or your own website. A post goes to a social network; an
article goes to a website. Requesting an article spends AI credits (scope `ai`) and comes
back `queued`; poll for the draft:

```elixir
{:ok, article} =
  FoPost.Blog.request_article(client, workspace.id,
    keyword: "social media calendar",
    instructions: "Lead with the calendar view."
  )

{:ok, draft} = FoPost.Blog.get_article(client, workspace.id, article.id)
{:ok, _article} = FoPost.Blog.submit_article(client, workspace.id, article.id)
{:ok, _article} = FoPost.Blog.publish_article(client, workspace.id, article.id)
```

Nothing publishes without approval: `submit_article/3` moves a draft to `"in_review"`,
and only then can it be published or scheduled. Planning is goal-driven: set `:goal`,
`:focus_topics` and `:avoid_topics` on the settings, refine search terms with
`suggest_seeds/3`, then research them with `plan_keywords/3`. Results land as
`"suggested"` keywords; setting one to `"planned"` picks it for the next free date.

A website target receives each published article as a signed request, with the secret
`FoPost.Blog.update_settings/3` returns once when `:website_url` is first set. Verify it
like any other delivery and read the payload as `FoPost.BlogArticlePublished`:

```elixir
{:ok, raw, conn} = Plug.Conn.read_body(conn)

with {:ok, %{event: "article.published"} = event} <-
       FoPost.Webhooks.verify(raw, conn.req_headers, website_secret) do
  article = FoPost.BlogArticlePublished.from_map(event.data)
  store(article.slug, article.body_html, article.images)
end
```

Image URLs in the payload stay fetchable for six hours, so store your own copy.

## Analytics

```elixir
# 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:

```elixir
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.

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

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

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

## WhatsApp Business

`FoPost.Whatsapp` reaches a WhatsApp Business number the customer already owns. The
platform owns the templates, flows, profile and commerce settings, so every call is live
and all of it answers 503 until WhatsApp is set up. Scope `accounts`, except the sandbox,
which sends a template and needs `publish`.

```elixir
{:ok, profile} = FoPost.Whatsapp.profile(client, account_id)
profile.quality_rating

# Filing a template returns the review status the platform gave it.
{:ok, template} =
  FoPost.Whatsapp.create_template(client, account_id,
    name: "order_shipped",
    language: "en_US",
    category: "UTILITY",
    components: [%{"type" => "BODY", "text" => "Your order is on its way."}]
  )

template.status
#=> "PENDING", until the platform approves it

# A flow is created as a draft, its screens uploaded, then published.
{:ok, flow} =
  FoPost.Whatsapp.create_flow(client, account_id,
    name: "Book a fitting",
    categories: ["LEAD_GENERATION"]
  )

{:ok, _} = FoPost.Whatsapp.upload_flow_json(client, account_id, flow.id, %{"version" => "7.0", "screens" => []})
{:ok, _} = FoPost.Whatsapp.publish_flow(client, account_id, flow.id)
{:ok, answers} = FoPost.Whatsapp.flow_responses(client, account_id)
```

## Examples

[`examples/create_post.exs`](examples/create_post.exs) creates a draft, preflights it, and
publishes it.

## Chatbots and the inbox

The [chat adapter](https://fopost.com/docs/sdks/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](https://fopost.com/docs/webhooks/verification)
   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

```bash
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](https://github.com/fopost/fopost-elixir/issues); anything else to
[fopost.com/contact](https://fopost.com/contact).

## License

MIT © Porter Bridge, LLC. See [LICENSE](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`:

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