Dregs Elixir SDK

Hex.pm Hexdocs License

The official Elixir client for Dregs, 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:

def deps do
[
{:dregs, "~> 0.1"}
]
end

It requires Elixir 1.15 or newer, and depends on Req for HTTP and 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.

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

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.

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.

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

{: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. Dregs.track!/3 returns the result directly and raises those errors.

Reading scores

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

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.

{: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

{: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

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

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:

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:

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

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:

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:

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

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 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. The short version:

mix deps.get
mix test
mix format --check-formatted
mix compile --warnings-as-errors
mix dialyzer

License

MIT. See LICENSE.