# `FaroEx`
[🔗](https://gitlab.com/estudio-yobo/faro/blob/v0.1.0/lib/faro_ex.ex#L1)

Send push notifications through a [Faro](https://gitlab.com/estudio-yobo/faro) server.

Build a client for one app, then send. Payloads are plain string-keyed maps in the wire
shape, and `FaroEx.Audience` supplies helpers that keep tag audiences readable.

    client = FaroEx.client(base_url: "https://push.example.com", api_key: System.fetch_env!("FARO_KEY"))

    FaroEx.send(client,
      title: "Flash sale",
      body: "50% off for the next 3 hours",
      audience: %{
        "include" => [
          FaroEx.Audience.users(["42", "43"]),
          FaroEx.Audience.tag("plan", "pro")
        ]
      }
    )
    #=> {:ok, %{notification: %{...}, delivery: %{"sent" => 12, ...}}}

## Failures are returned, never raised

`{:error, %FaroEx.Error{}}` carries the HTTP status and the server's own error code.
A transport failure arrives as `status: nil, code: "transport_error"`.

## No retries, by design

`Faro` has no idempotency key on its send endpoint, so a blind retry after a timeout
pushes the same notification to real phones twice. Nothing here retries — configure
retries in your own code only if you can accept a duplicate.

# `client`

```elixir
@spec client(keyword()) :: FaroEx.Client.t()
```

Builds a client for one Faro app.

## Options

  * `:base_url` (required) — origin of the Faro server, e.g. `"https://push.example.com"`.
  * `:api_key` (required) — an app API key, `"<appId>.<secret>"`. Mint one with the
    `notifications:read` and `notifications:write` scopes; this library does nothing else.
  * `:connect_timeout` — milliseconds, default 5000.
  * `:receive_timeout` — milliseconds, default 120000. A send blocks
    for the server's inline drain, which is 10–20 seconds for a ~1000-user audience, so
    this is deliberately generous.

The app id is read out of the token, so a key and an app id can never disagree. Raises
`ArgumentError` for a token that is not an app API key — an operator token or a public
client key (`yk_…`) fails here, at boot, rather than as a 403 at 3am.

# `deliveries`

```elixir
@spec deliveries(FaroEx.Client.t(), String.t(), keyword()) ::
  {:ok, [map()]} | {:error, FaroEx.Error.t()}
```

Lists the deliveries of one notification.

## Options

  * `:status` — `"pending"`, `"sending"`, `"sent"`, `"failed"`, `"invalid"` or `"skipped"`.
  * `:limit` — 1 to 1000, default 200 server-side.

# `get_notification`

```elixir
@spec get_notification(FaroEx.Client.t(), String.t()) ::
  {:ok, %{notification: map() | nil, deliveries: [map()]}}
  | {:error, FaroEx.Error.t()}
```

Reads a notification back, with the deliveries frozen for it.

This is how you learn the real outcome: a send returns a snapshot, and the worker keeps
finishing retries and delayed sends in the background. `deliveries` is the list the API
returns (up to 200 rows); returns `{:error, %FaroEx.Error{code: "not_found"}}` for an
unknown id.

# `send`

```elixir
@spec send(FaroEx.Client.t(), keyword()) ::
  {:ok, %{notification: map() | nil, delivery: map() | nil}}
  | {:error, FaroEx.Error.t()}
```

Sends a notification, returning what was accepted and what has been delivered so far.

## Options

  * `:title`, `:subtitle`, `:body`, `:data`, `:priority`, `:ttl`, `:url`, `:icon`,
    `:image`, `:launch_url`, `:channel_id`, `:collapse_id`, `:send_after_seconds`,
    `:audience` — the notification content as the API defines it. `:data` is an
    arbitrary map handed to the client app; `:audience` is an audience map (see
    `FaroEx.Audience`). Options you leave out are not sent.

## Returns

    {:ok, %{notification: %{...}, delivery: %{"sent" => 12, ...}}}

Both maps are the server's JSON, decoded with string keys. The counts are a snapshot:
retries of provider hops finish in the background, so a `"pending"` count above zero
means there is more to come — poll `get_notification/2` for the final tally.

A send accepted with zero deliveries (a user with no device) is **`{:ok, _}`**: the
request landed, and an empty audience is not an error.

This call blocks for the server's inline drain — roughly 10–20 seconds for a
~1000-user audience — so run it under your own supervisor
(`Task.Supervisor.async_nolink/3`) rather than inside a bare request cycle.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
