dregs 1.0.0

The official Rust client for Dregs, which scores the users of your application for fraud and abuse.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
# Dregs Rust SDK

[![crates.io](https://img.shields.io/crates/v/dregs.svg)](https://crates.io/crates/dregs)
[![docs.rs](https://img.shields.io/docsrs/dregs)](https://docs.rs/dregs)
[![License](https://img.shields.io/crates/l/dregs.svg)](LICENSE)

The official Rust 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.

```bash
cargo add dregs
```

The client is async and runs on [Tokio](https://tokio.rs). Rust 1.85 or newer.

## 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.

```rust
use dregs::Client;

let client = Client::new(std::env::var("DREGS_SECRET_KEY")?)?;
```

`Client::from_env()` reads the key from `DREGS_SECRET_KEY` for you, which is usually all you need. The
client holds a connection pool: build one at startup and keep it, rather than making a new one per
request. Cloning is cheap and every clone shares the pool, so put it in your application state and hand
clones to handlers and tasks freely.

## Tracking events

```rust
use dregs::Event;
use serde_json::json;

client
    .track(
        Event::new("user.signup", "user_12345")
            .data(json!({ "plan": "pro", "referrer": "partner-x" }))
            .identity_data(json!({ "email": "ada@example.com", "name": "Ada Lovelace" })),
    )
    .await?;
```

The second argument to `Event::new` 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.

`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.

`data` and `identity_data` take anything that serializes to a JSON object: a `json!` value, a map, or
your own `#[derive(Serialize)]` struct.

### 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 empty, and what
`Group::organization` gives you), 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.

```rust
use dregs::{Event, Group};

client
    .track(
        Event::new("user.login", "user_12345")
            .group(Group::organization("org_678").data(json!({ "name": "Acme Inc", "plan": "enterprise" })))
            .group(Group::new("team", "team_42").data(json!({ "name": "Payments" }))),
    )
    .await?;
```

### 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.

```rust
client
    .track(Event::new("purchase", "user_12345").event_id(format!("order-{}", order.id)))
    .await?;
```

When you omit it the SDK generates one, which is what makes its own retries safe.

### What comes back

```rust
let result = client.track(Event::new("user.signup", "user_12345")).await?;

result.accepted(); // true when Dregs recorded the event
result.id; // the event's id
```

`accepted()` 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 errors instead — see [Errors](#errors).

## Reading scores

```rust
let scores = client.identities().scores("user_12345").await?;

scores.humanity(); // Some(85)
scores.authenticity(); // Some(72)
scores.uniqueness(); // Some(91)
scores.behavior(); // Some(68)
```

This is the cheap read and the one most integrations want. A category Dregs has not scored yet reads as
`None`, and a brand-new identity comes back empty. `Scores` also dereferences to a slice of `Score`, so
you can iterate it.

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 `track()` call.

```rust
if scores.authenticity().is_some_and(|authenticity| authenticity < 40) {
    hold_for_review("user_12345").await;
}
```

### 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.

```rust
let analysis = client.identities().analysis("user_12345").await?;

for observation in analysis.observations() {
    println!(
        "{}: {} (value {:?})",
        observation.label.as_deref().unwrap_or_default(),
        observation.explanation.as_deref().unwrap_or_default(),
        observation.value,
    );
}
```

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`. `analysis()`
returns a `NotFound` error until the identity has been analyzed at least once.

### The whole identity

```rust
let identity = client.identities().get("user_12345").await?;

identity.display_email; // Some("ada@example.com")
identity.humanity_score; // Some(85)
identity.badges; // [Badge { name: Some("Account Takeover Suspected"), .. }]
identity.data; // every attribute you have sent
```

### Forcing a rescore

```rust
client.identities().analyze("user_12345").await?;
```

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.

### History, links, and past cycles

```rust
let history = client.identities().history("user_12345", Some(10)).await?;

for entry in &history {
    println!("{:?} {:?} {:?}", entry.finished_at, entry.humanity, entry.behavior);
}

// A past cycle's observations.
if let Some(cycle_id) = history.last().and_then(|entry| entry.cycle_id) {
    let older = client.identities().analysis_cycle("user_12345", cycle_id).await?;
}

for link in client.identities().links("user_12345").await? {
    println!("{:?} {:?} {:?}", link.identity_id, link.link_type, link.explanation); // e.g. "user_999", "SAME_DEVICE"
}
```

### Disregarding an identity

Leave your own staff and test accounts out of scoring and escalations:

```rust
use dregs::IdentityUpdate;

client
    .identities()
    .update("qa_bot_7", IdentityUpdate::new().disregarded(true))
    .await?;
```

`IdentityUpdate::attributes` merges attributes into the identity the way `identity_data` on an event
does. An update that sets nothing is refused before any request.

### Asking for a review

When a score looks wrong, Dregs's AI reviewer will look over the analysis and say why. The review runs in
the background, so read it back later with `reviews()`.

```rust
let review = client
    .identities()
    .review("user_12345", Some("This is a long-standing customer."))
    .await?;

review.status; // Some("PENDING"), then Some("FINISHED")
```

## Listing and paging

Every namespace with a `list()` returns one `Page` at a time, numbered from 0, and a `list_all()` that
walks every page for you as a [`Stream`](https://docs.rs/futures-core/latest/futures_core/stream/trait.Stream.html).
Each `list()` takes a builder for its filters and paging:

```rust
use dregs::{ListIdentities, Order};
use futures_util::TryStreamExt; // or futures::TryStreamExt

let page = client
    .identities()
    .list(ListIdentities::new().term("example.com").page_size(50))
    .await?;

page.total_elements; // how many match in all
page.has_next(); // whether another page follows

let mut identities = client.identities().list_all(
    ListIdentities::new().group("org_678").sort("humanityScore").order(Order::Asc),
);

while let Some(identity) = identities.try_next().await? {
    // ...
}
```

Filters you leave out are not sent. A page holds at most 100 items. `Page` dereferences to a slice of its
items, and the stream owns a clone of the client, so it can be moved into a spawned task. A page that
fails to load ends the stream with that error.

## Groups

`track()` records groups and memberships as events arrive. The `groups()` namespace reads them back, and
keeps Dregs in step when something changes outside an event, such as a user leaving a team:

```rust
use dregs::{Group, ListGroups};

client
    .groups()
    .create(Group::organization("org_678").data(json!({ "name": "Acme Inc", "plan": "enterprise" })))
    .await?;

let group = client.groups().get("organization", "org_678").await?;
group.identity_count; // Some(12)

client
    .groups()
    .update("organization", "org_678", json!({ "plan": "business" }))
    .await?;

client.groups().add_member("team", "team_42", "user_12345").await?;
client.groups().remove_member("team", "team_42", "user_12345").await?;

let mut teams = client
    .groups()
    .list_all(ListGroups::new().group_type("team").identity("user_12345"));

while let Some(team) = teams.try_next().await? {
    println!("{:?} {:?}", team.id, team.display_name);
}
```

`create()` takes the same `Group` builder as `track()`, and merges into the group when one with the same
type and id already exists, so it is safe to call on every sync. The type defaults to `"organization"`.
The groups Dregs returns are `TrackedGroup`s, to keep them apart from the builder.

## Events, devices, and escalations

```rust
use dregs::{EscalationSeverity, EscalationStatus, ListEscalations, ListEvents};

let mut events = client.events().list_all(ListEvents::new().identity("user_12345"));

while let Some(event) = events.try_next().await? {
    println!("{:?} {:?} {:?}", event.timestamp, event.name, event.canonical_type);
}

client.devices().update("a1b2c3d4e5", true).await?; // disregard, e.g. a shared office machine

let mut escalations = client.escalations().list_all(
    ListEscalations::new()
        .status(EscalationStatus::Open)
        .severity(EscalationSeverity::Critical),
);

while let Some(escalation) = escalations.try_next().await? {
    notify_on_call(&escalation).await;

    if let Some(id) = escalation.id {
        client.escalations().update(id, EscalationStatus::Acknowledged).await?;
    }
}

let summary = client.escalations().summary().await?; // open, acknowledged, and closed counts
```

The events Dregs returns are `TrackedEvent`s, to keep them apart from the `Event` builder `track()` takes.

## Errors

Every call returns `dregs::Result<T>`, whose error is `dregs::Error`. When Dregs answered with an error,
`error.kind()` says which:

```rust
use dregs::ApiErrorKind;

match client.track(Event::new("user.signup", "user_12345")).await {
    Ok(result) => { /* check result.accepted() */ }
    Err(error) => match error.kind() {
        Some(ApiErrorKind::QuotaExceeded) => { /* over the monthly event limit; the event was not queued */ }
        Some(ApiErrorKind::RateLimited) => {
            // Ingesting too fast. When the server said how long to wait:
            let wait = error.api_error().and_then(|api| api.retry_after);
        }
        _ => { /* anything else */ }
    },
}
```

| Error | When |
| --- | --- |
| `ApiErrorKind::BadRequest` | 400, the event was malformed |
| `ApiErrorKind::Authentication` | 401, the secret key was not recognized |
| `ApiErrorKind::QuotaExceeded` | 402, the account is over its monthly event limit |
| `ApiErrorKind::PermissionDenied` | 403, the credential may not do this |
| `ApiErrorKind::NotFound` | 404, no such identity, group, event, device, or escalation, or no analysis yet |
| `ApiErrorKind::RateLimited` | 429, too many requests |
| `ApiErrorKind::Server` | 5xx |
| `Error::Timeout` | the request timed out |
| `Error::Connection` | the request never reached Dregs |
| `Error::InvalidArgument` | refused before sending, such as an empty identity or an update that sets nothing |

The API errors are `Error::Api`, carrying an `ApiError` with the `status`, `message`, parsed `body`, and
`request_id`. When the error body carries no message, the message is the HTTP reason phrase, such as
`Not Found`.

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

```rust
let client = Client::builder().max_retries(5).build()?; // or 0 to handle it yourself
```

## Concurrency

Every call is an `async fn` that resolves when Dregs has answered, so awaiting `track()` in a request
handler makes that request wait on the round trip. Where the response does not depend on the outcome,
spawn it instead:

```rust
let client = client.clone();

tokio::spawn(async move {
    if let Err(error) = client.track(Event::new("user.signup", user_id)).await {
        tracing::warn!(%error, "Dregs did not record the signup");
    }
});
```

Retries happen inside the spawned task, and the event id is generated before the first attempt, so a
retry can never double-count.

## 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-serialized value will not match, because key order and whitespace
change.

```rust
use axum::{body::Bytes, http::{HeaderMap, StatusCode}};

async fn receive(headers: HeaderMap, body: Bytes) -> StatusCode {
    let signature = headers
        .get(dregs::webhooks::SIGNATURE_HEADER)
        .and_then(|value| value.to_str().ok())
        .unwrap_or_default();

    match dregs::webhooks::verify(&body, signature, &webhook_secret()) {
        Ok(event) => {
            handle(event);
            StatusCode::NO_CONTENT
        }
        Err(_) => StatusCode::BAD_REQUEST,
    }
}
```

`verify` also rejects payloads older than five minutes as replays; use `verify_with` and
`VerifyOptions::default().tolerance(None)` 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

```rust
use std::time::Duration;

let client = Client::builder()
    .secret_key(key) // defaults to $DREGS_SECRET_KEY
    .base_url("https://dregs.com/api") // defaults to $DREGS_BASE_URL, then https://dregs.com/api
    .timeout(Duration::from_secs(10))
    .max_retries(2)
    .http_client(reqwest::Client::new()) // bring your own for proxies or custom TLS
    .build()?;
```

### TLS

TLS goes through [rustls](https://github.com/rustls/rustls) by default, verifying certificates with the
platform's verifier. To use the operating system's TLS library instead:

```toml
dregs = { version = "1", default-features = false, features = ["native-tls"] }
```

## Types

Responses are plain structs with public fields. Each one also keeps the 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 not the expected type reads as `None` rather than failing the
call. The response structs are `#[non_exhaustive]`, so new fields arrive in minor releases.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md). The short version:

```bash
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --check
```

## 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).