faro_ex

Send push notifications through a Faro server from Elixir.

Faro is a push-only notification service: you manage apps, devices and tags there, and this library covers the part a backend needs — sending a notification and reading back what was sent. Device registration, tags from the phone and open reporting belong to the mobile SDK, @faro/react-native.

Install

# mix.exs
def deps do
  [{:faro_ex, "~> 0.1.0"}]
end

Until 0.1.0 is on hex.pm, point at the checkout instead:

{:faro_ex, path: "../faro/sdk_ex"}

Requires Elixir ~> 1.15.

Setup

1. Mint a least-privilege key. In the Faro dashboard, open the app → API keys → create a key with exactly notifications:read and notifications:write. A key holding apps:write is a superuser for the app, and this library never needs one.

The token looks like <appId>.<secret>. Keep it out of the repo — config/runtime.exs reading System.fetch_env!/1 is the usual place:

# config/runtime.exs
config :my_app, :faro,
  base_url: System.get_env("FARO_BASE_URL", "https://push.example.com"),
  api_key: System.fetch_env!("FARO_KEY")

2. Build the client where you have config — a client is stateless data, so there is nothing to supervise, and reading the key at runtime keeps it out of the compile artifacts:

# lib/my_app/push.ex
defmodule MyApp.Push do
  @moduledoc false

  def client do
    config = Application.fetch_env!(:my_app, :faro)
    FaroEx.client(base_url: config[:base_url], api_key: config[:api_key])
  end
end

There is no application-environment default inside the library on purpose: an explicit client is the thing that still works the day a second app or tenant appears.

3. The app id comes from the token. FaroEx.client/1 reads it out of <appId>.<secret>, so a key and an app id can never disagree. It raises ArgumentError for anything that is not an app key — an operator token, or a public client key (yk_…) — so a bad paste fails at boot instead of as a 403 at 3am.

4. The server must support externalUserIds. Per-user sends use the audience selector field added in Faro's task #1871. Against an older Faro, FaroEx.Audience.users/1 comes back as a validation_error.

Sending

alias FaroEx.Audience

FaroEx.send(MyApp.Push.client(),
  title: "Your order shipped",
  body: "Tracking is available now",
  data: %{"screen" => "orders", "orderId" => "A-1042"},
  audience: %{"include" => [Audience.users(["42"])]}
)
#=> {:ok, %{
#     notification: %{"id" => "ntf_…", "status" => "completed", …},
#     delivery: %{"total" => 1, "pending" => 0, "sent" => 1, "failed" => 0, …}
#   }}

Options: :title, :subtitle, :body, :data, :priority ("low" | "normal" | "high"), :ttl, :url, :icon, :image, :launch_url, :channel_id, :collapse_id, :send_after_seconds, :audience. Anything you leave out is not sent. An option the library does not know raises rather than being dropped, because a silently ignored :titel looks exactly like a notification with no title.

Audiences

Include selectors are unioned; exclude selectors are subtracted; an empty include means every subscribed device.

audience: %{
  "include" => [
    Audience.users(["42", "43"]),            # %{"externalUserIds" => [...]}
    Audience.tag("plan", "pro"),             # plan equals pro
    Audience.tag("country", :in, ["es", "mx"])
  ],
  "exclude" => [Audience.tag("plan", :not_in, ["trial"])]
}

Operators: :equals (default), :not_equals, :contains, :starts_with, :in, :not_in, :exists — the camelCase wire spelling is accepted too. Helpers return plain maps and validate nothing: the server's schemas are the contract, and a second validator would drift.

Inside one selector, deviceIds and externalUserIds name devices and are OR-ed together; platforms and filters narrow that set with AND. So a selector can be %{"externalUserIds" => ["42"], "platforms" => ["ios"]} — "user 42, on iOS only".

An external user id with no device matches nothing, and the send is still {:ok, _} with "total" => 0. That is not an error: people uninstall apps.

Reading back

{:ok, %{notification: notification, deliveries: deliveries}} =
  FaroEx.get_notification(client, notification["id"])

{:ok, failed} = FaroEx.deliveries(client, notification["id"], status: "failed", limit: 50)

send/2 returns a snapshot: provider retries and delayed sends keep running on the server. Poll get_notification/2 when you need the final tally.

Errors

case FaroEx.send(client, body: "hi", audience: audience) do
  {:ok, %{delivery: %{"sent" => sent}}} -> sent
  {:error, %FaroEx.Error{status: 403}} -> :not_allowed
  {:error, %FaroEx.Error{code: "credentials_not_configured"}} -> :configure_apns_first
  {:error, %FaroEx.Error{status: nil, code: "transport_error"} = error} -> Logger.warning(Exception.message(error))
end

code is the server's own string, passed through verbatim: validation_error, unauthorized, forbidden, not_found, conflict, credentials_not_configured, bad_request, internal_error. A transport failure (refused connection, timeout) arrives with status: nil and code: "transport_error". Codes are never turned into atoms.

No retries

Faro's send endpoint has no idempotency key, so a retry after a timeout can push the same notification to real phones twice. This library therefore makes exactly one request and returns what happened; retry: false is set on the underlying HTTP client so nothing creeps in underneath. Retry in your own code only if a duplicate is acceptable.

It blocks, so supervise it

send/2 waits for the server's inline drain: one provider request per device, so a ~1000-user audience takes roughly 10–20 seconds. Do not run it inside a bare Phoenix request; start it under a Task.Supervisor and broadcast the result over PubSub.

Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
  result = FaroEx.send(MyApp.Push.client(), title: "…", body: "…", audience: audience)
  Phoenix.PubSub.broadcast(MyApp.PubSub, "notifications", {:sent, result})
end)

Timeouts default to connect_timeout: 5_000 and receive_timeout: 120_000 (milliseconds) and are options on FaroEx.client/1.

Telemetry

One event triplet per request:

:telemetry.attach_many(
  "faro-ex-logger",
  [[:faro_ex, :request, :start], [:faro_ex, :request, :stop], [:faro_ex, :request, :exception]],
  &__MODULE__.handle_faro_event/4,
  nil
)

stop carries %{duration: native_time} with metadata %{method, path, status}; exception carries %{kind, reason, stacktrace}. status is nil for a transport failure.

Development

mix deps.get
mix test                    # 25 tests, Bypass-backed: a real local HTTP server
mix format --check-formatted
mix compile --warnings-as-errors

Tests never touch a Faro instance. Bypass stands up a real Cowboy server on a random port, so requests exercise real Req, real encoding and real decoding — there is no test-only transport seam in lib/.