Packages
The official Elixir client for Dregs: track backend events and read fraud and abuse scores for the users of your application.
Current section
Files
Jump to
Current section
Files
README.md
# Dregs Elixir SDK
[](https://hex.pm/packages/dregs)
[](https://hexdocs.pm/dregs)
[](LICENSE)
The official Elixir client for [Dregs](https://dregs.com), which scores the users of your application
for fraud and abuse across four categories: humanity, authenticity, uniqueness, and behavior.
Send events from your backend, read back the scores and the observations behind them.
Add `dregs` to the dependencies in your `mix.exs`:
```elixir
def deps do
[
{:dregs, "~> 0.1"}
]
end
```
It requires Elixir 1.15 or newer, and depends on [Req](https://hex.pm/packages/req) for HTTP and
[Jason](https://hex.pm/packages/jason) for JSON, which most Elixir applications already have.
## Getting started
You need the **secret key** from an API credential, which you will find under **Settings → Credentials**
in the Dregs dashboard. It starts with `sk_`. The `pk_` public key is for the browser tracker and cannot
read identities or scores.
```elixir
client = Dregs.Client.new(secret_key: System.fetch_env!("DREGS_SECRET_KEY"))
```
The key is read from `DREGS_SECRET_KEY` when you do not pass one, so `Dregs.Client.new()` on its own is
usually enough. A client is a plain struct holding configuration: building one opens no connections,
and every client shares Req's connection pool. Build one at startup and pass it around, or build one
where you need it; either is cheap.
## Tracking events
```elixir
Dregs.track(client, "user.signup",
identity: "user_12345",
data: %{plan: "pro", referrer: "partner-x"},
identity_data: %{email: "ada@example.com", name: "Ada Lovelace"}
)
```
`:identity` is your own id for the user — the same one you pass to `dregs.identify()` in the browser
tracker, and the one you look scores up by. It is required: a server-side event carries no device
signature, so the identity is the only thing tying the event to a user. It must be a string, so pass
`to_string(user.id)` if your ids are integers.
`:identity_data` carries attributes of the *user* rather than the event. The analyzers lean on these
heavily, so send them whenever you have them. Name the keys the way your application already does and
map them to Dregs's canonical fields under **Settings → Mappings**; the same goes for event names.
### Groups
If your application groups users into organizations, teams, workspaces, or the like, pass the groups the
user is acting in. Dregs records each group and makes the identity a member of it. Each group has your
own `id`, a `type` that is your own name for the kind of group (`"organization"` when omitted), and
optional `data` that Dregs merges into the group, so later events can send the type and id alone. Dregs
normalizes types to lower_snake_case, so `ParentCompany` and `parent-company` are the same type, and an
event can carry one group of each type.
```elixir
Dregs.track(client, "user.login",
identity: "user_12345",
groups: [
%{type: "organization", id: "org_678", data: %{name: "Acme Inc", plan: "enterprise"}},
%{type: "team", id: "team_42", data: %{name: "Payments"}}
]
)
```
Group maps may use atom or string keys, and an integer id is sent as a string.
### Idempotency
Every event is sent with an `id`, which makes ingestion idempotent: reposting the same id returns the
original event instead of recording a second one. Pass the id your application already has, and a retry
after a timeout can never double-count.
```elixir
Dregs.track(client, "purchase", identity: "user_12345", event_id: "order-#{order.id}")
```
When you omit it the SDK generates one, which is what makes its own retries safe. It generates a new one
on every call, though, so when something outside the SDK retries the call (a background job, say), pass
an id of your own.
### What comes back
```elixir
{:ok, result} = Dregs.track(client, "user.signup", identity: "user_12345")
Dregs.TrackResult.accepted?(result) # true when Dregs recorded the event
result.id # the event's id
```
`accepted?/1` is `false` in the uncommon case where Dregs accepts the request without recording an
event. Failures that are yours to act on come back as `{:error, ...}` instead — see [Errors](#errors).
`Dregs.track!/3` returns the result directly and raises those errors.
## Reading scores
```elixir
{:ok, scores} = Dregs.Identities.scores(client, "user_12345")
scores.humanity # 85
scores.authenticity # 72
scores.uniqueness # 91
scores.behavior # 68
```
This is the cheap read and the one most integrations want. A category Dregs has not scored yet reads as
`nil`, and a brand-new identity comes back empty. `Dregs.Scores` implements `Enumerable` over each
category's `Dregs.Score`, so `Enum` functions work on it directly.
Scoring is **asynchronous**. Scores appear moments after the events that move them, not in the same
breath, so read them at a decision point rather than immediately after a `Dregs.track/3` call.
```elixir
if scores.authenticity != nil and scores.authenticity < 40 do
hold_for_review("user_12345")
end
```
### Seeing exactly why
The scores are the summary; the observations are the evidence. When you need to show or log *why* an
identity scored the way it did, ask for the analysis.
```elixir
{:ok, analysis} = Dregs.Identities.analysis(client, "user_12345")
for observation <- analysis.observations do
IO.puts("#{observation.label}: #{observation.explanation} (value #{observation.value})")
end
```
Each observation carries the analyzer that produced it, a `value` from 0.0 (suspicious) to 1.0
(legitimate), a `confidence`, a `weight`, and the counts behind the finding in `metadata`.
`Dregs.Identities.analysis/2` returns a `:not_found` error until the identity has been analyzed at least
once.
### The whole identity
```elixir
{:ok, identity} = Dregs.Identities.get(client, "user_12345")
identity.display_email # "ada@example.com"
identity.humanity_score # 85
identity.badges # [%Dregs.Badge{name: "Account Takeover Suspected", ...}]
identity.data # every attribute you have sent
```
### Forcing a rescore
```elixir
:ok = Dregs.Identities.analyze(client, "user_12345")
```
This queues the work and returns; it does not wait for the cycle to finish. Dregs rescores on its own
as events arrive, so you rarely need this outside of a support or backfill flow.
## Errors
Every function that talks to Dregs returns `{:ok, value}` or `{:error, exception}`, and has a `!` twin
that returns the value or raises the exception. The exception is a `Dregs.APIError` when Dregs answered
with an error, and a `Dregs.ConnectionError` when the request never got an answer. Match on the
`:reason` field to handle the cases you care about:
```elixir
case Dregs.track(client, "user.signup", identity: "user_12345") do
{:ok, result} ->
result
{:error, %Dregs.APIError{reason: :quota_exceeded}} ->
# over the monthly event limit; the event was not queued
:dropped
{:error, %Dregs.APIError{reason: :rate_limited, retry_after: seconds}} ->
# ingesting too fast; seconds is set when the server said how long
{:retry_in, seconds}
{:error, error} ->
# anything else this library returns
Logger.warning("Dregs call failed: #{Exception.message(error)}")
end
```
| Error | `:reason` | When |
| --- | --- | --- |
| `Dregs.APIError` | `:bad_request` | 400, the event was malformed |
| `Dregs.APIError` | `:authentication` | 401, the secret key was not recognized |
| `Dregs.APIError` | `:quota_exceeded` | 402, the account is over its monthly event limit |
| `Dregs.APIError` | `:permission_denied` | 403, the credential may not do this |
| `Dregs.APIError` | `:not_found` | 404, no such identity, or it has not been analyzed |
| `Dregs.APIError` | `:rate_limited` | 429, too many requests |
| `Dregs.APIError` | `:server_error` | 5xx |
| `Dregs.APIError` | `:api_error` | any other error status |
| `Dregs.ConnectionError` | `:timeout` | the request timed out |
| `Dregs.ConnectionError` | the transport's reason, such as `:econnrefused` | the request never reached Dregs |
A `Dregs.APIError` also carries the `:status`, the parsed `:body`, and the `:request_id`, which is worth
logging if you ever need to ask about a request. A mistake in how you called the SDK — a missing secret
key, an event id the API would refuse — raises `ArgumentError` before anything leaves the process, from
the plain functions as well as the `!` ones, because it is a bug to fix rather than a condition to
handle.
### Retries
Connection failures, timeouts, 429s, and 5xx are retried automatically with exponential backoff and
jitter, honouring `Retry-After` when the server sends one. Two retries by default:
```elixir
client = Dregs.Client.new(max_retries: 5) # or 0 to handle it yourself
```
## Concurrency
Every call blocks the calling process until Dregs answers or the retries run out. On the BEAM that is
the right default: blocking one process blocks nothing else, so there is one set of functions rather
than a synchronous and an asynchronous twin.
If you do not want a signup to wait on an HTTP round trip, make the call from another process. A
supervised task is enough when losing the occasional event to a restart is acceptable:
```elixir
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
Dregs.track(client, "user.signup", identity: to_string(user.id), identity_data: %{email: user.email})
end)
```
When it is not, use a background job, such as an [Oban](https://hex.pm/packages/oban) worker. Pass an
`:event_id` of your own, because the job may run more than once and the SDK would otherwise generate a
fresh id on each run:
```elixir
defmodule MyApp.Workers.TrackSignup do
use Oban.Worker, queue: :dregs
@impl Oban.Worker
def perform(%Oban.Job{args: %{"user_id" => user_id, "email" => email}}) do
case Dregs.track(Dregs.Client.new(), "user.signup",
identity: to_string(user_id),
identity_data: %{email: email},
event_id: "signup-#{user_id}"
) do
{:ok, _result} -> :ok
{:error, %Dregs.APIError{reason: :quota_exceeded}} -> {:cancel, :quota_exceeded}
{:error, error} -> {:error, error}
end
end
end
```
## Webhooks
Dregs signs every webhook with the channel's signing secret. Verify it against the **raw request body**
before acting on the payload — a re-encoded map will not match, because key order and whitespace
change.
In Phoenix and Plug, `Plug.Parsers` consumes the body to decode it, so keep a copy as it is read with
the parser's `:body_reader` option:
```elixir
defmodule MyAppWeb.CacheBodyReader do
def read_body(conn, opts) do
with {:ok, body, conn} <- Plug.Conn.read_body(conn, opts) do
{:ok, body, update_in(conn.assigns[:raw_body], &[body | &1 || []])}
end
end
end
# In your endpoint:
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
body_reader: {MyAppWeb.CacheBodyReader, :read_body, []},
json_decoder: Phoenix.json_library()
```
Then verify in the controller:
```elixir
def receive(conn, _params) do
payload = conn.assigns.raw_body |> Enum.reverse() |> IO.iodata_to_binary()
signature = conn |> get_req_header("x-dregs-signature") |> List.first()
case Dregs.Webhooks.verify(payload, signature, System.fetch_env!("DREGS_WEBHOOK_SECRET")) do
{:ok, event} ->
handle(event)
send_resp(conn, 204, "")
{:error, %Dregs.WebhookVerificationError{}} ->
send_resp(conn, 400, "")
end
end
```
The body reader keeps a copy of every request body it reads. If that is more than you want, check
`conn.request_path` in `read_body/2` and keep the copy only for the webhook route.
`verify/4` also rejects payloads older than five minutes as replays; pass `tolerance: nil` to skip that
if you are deduplicating on the event id yourself. The signing secret is shown once, when you create the
webhook channel, and is not your API secret key.
## Configuration
```elixir
client = Dregs.Client.new(
secret_key: nil, # defaults to $DREGS_SECRET_KEY
base_url: nil, # defaults to $DREGS_BASE_URL, then https://dregs.com/api
timeout: 10_000, # milliseconds, for connecting and then for the response
max_retries: 2,
req_options: [] # merged into every Req request, for proxies, custom TLS, or your own Finch pool
)
```
`:req_options` is also how you keep Dregs out of your own test suite: `req_options: [plug: {Req.Test,
MyApp.Dregs}]` sends every call to a [`Req.Test`](https://hexdocs.pm/req/Req.Test.html) stub instead of
the network.
## Typespecs
Every public function has a `@spec` and every struct a `@type`, so Dialyzer and your editor see the full
surface. Responses are plain structs; each one also keeps the decoded body it was built from in `:raw`,
so a field Dregs adds after this release is reachable without waiting for an SDK upgrade. Parsing is
lenient: a field that is missing or of an unexpected type reads as `nil` rather than failing the call.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). The short version:
```bash
mix deps.get
mix test
mix format --check-formatted
mix compile --warnings-as-errors
mix dialyzer
```
## Links
- [Dregs manual](https://dregs.com/manual/) and [REST API reference](https://dregs.com/manual/api/)
- [Dregs MCP server](https://github.com/dregs-sdk/dregs-mcp), for connecting AI agents to your data
- [Security policy](SECURITY.md)
## License
MIT. See [LICENSE](LICENSE).