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.
- Plain maps in and out: the wire shape is the shape, no structs to learn.
FaroEx.Audiencehelpers make tag and per-user targeting readable.- Failures are returned, never raised:
{:error, %FaroEx.Error{status: 403, code: "forbidden"}}. - No retries, deliberately — see No retries.
Install
# mix.exs
def deps do
[{:faro_ex, "~> 0.1.0"}]
endUntil 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
endThere 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))
endcode 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/.