Current section
Files
Jump to
Current section
Files
README.md
# santati
Official Elixir SDK for the [Santati](https://github.com/jamescarr/santati-sdks) audit-log API:
emit one audit event or a batch, then list and iterate over what was recorded.
The wire surface is shared with the Python, TypeScript, Go, Rust, Ruby and PHP
SDKs and pinned by this repository's conformance suite.
## Installation
Add `santati` to your dependencies:
```elixir
def deps do
[
{:santati, "~> 0.1"}
]
end
```
## Quickstart
```elixir
{:ok, client} =
Santati.new(
api_key: System.fetch_env!("SANTATI_API_KEY"),
trail: "billing"
)
# Emit one event. The trail resolves from the event first, then the client.
{:ok, result} =
Santati.Events.emit(client, %{
event: "invoice.voided",
organization_id: "org_acme",
actor: %{type: "user", id: "usr_123", name: "Dana Ortiz"},
targets: [%{type: "invoice", id: "inv_555"}],
data: %{amount_cents: 4200, currency: "usd"}
})
result.event.id
#=> "01J9Z6K3M4QX8RT2VN5B7HCWDA"
```
An emit without an `idempotency_key` gets a freshly generated UUIDv4, reused
verbatim by every retry of that emit — so a retried request can never index the
event twice. A replay answers with `duplicate: true`.
```elixir
# Emit a batch. Items that carry their own key keep it.
{:ok, batch} =
Santati.Events.emit_batch(client, [
%{event: "invoice.voided"},
%{event: "invoice.paid", trail: "payments"}
])
batch.accepted
#=> 2
batch.results
#=> [%Santati.BatchItem{index: 0, status: "accepted", ...}, ...]
```
A partially rejected batch answers `207` and is still an `{:ok, batch}`: read
`batch.rejected` and each item's `error` (`:code`, `:message`, `:field`).
```elixir
# List one page, newest first, and read the cursor of the next one.
{:ok, page} =
Santati.Events.list(client,
trail: "billing",
event: "invoice.voided",
created_after: "2026-09-01T00:00:00+00:00",
limit: 50
)
page.results
#=> [%SantatiCore.Model.AuditEvent{}, ...]
page.next_cursor
#=> "cD00ODY=" | nil
```
The client's default trail is never applied to reads, and only the parameters
you pass are sent.
```elixir
# Iterate every matching event, following next cursors lazily.
client
|> Santati.Events.stream(trail: "billing", limit: 500)
|> Stream.map(& &1.event)
|> Enum.take(10)
```
`stream/2` yields `SantatiCore.Model.AuditEvent` structs and raises the error of
the page that failed — events from earlier pages have already been yielded.
## Errors
`Santati.new/1`, `Santati.Events.emit/2`, `emit_batch/2` and `list/2` answer
`{:ok, result}` or `{:error, exception}`. Every exception carries the same five
attributes:
| kind | when |
|---|---|
| `Santati.ValidationError` | local validation (`status` is `nil`), or HTTP 400, 413, 422 |
| `Santati.AuthError` | HTTP 401, 403 |
| `Santati.NotFoundError` | HTTP 404 |
| `Santati.RateLimitedError` | HTTP 429 |
| `Santati.ServerError` | HTTP 5xx |
| `Santati.TransportError` | no response at all: refused, DNS, TLS, timeout (`status` is `nil`) |
| `Santati.ApiError` | any other status, an unexpected 2xx, or an undecodable 2xx body |
```elixir
case Santati.Events.emit(client, %{event: "invoice.voided"}) do
{:ok, result} -> result.event.id
{:error, %Santati.ValidationError{field: field}} -> {:invalid, field}
{:error, %Santati.AuthError{}} -> :unauthorized
{:error, %Santati.RateLimitedError{retry_after: seconds}} -> {:wait, seconds}
{:error, error} -> {:failed, Exception.message(error)}
end
```
## Retries
An emit, a batch and a list retry transport failures, `500`/`502`/`503`/`504`
and `429` (unless the code is `quota_exceeded`) up to `max_retries` times, with
the same body and the same idempotency keys. A `Retry-After` header is honoured
unless it is longer than `max_backoff_ms`, in which case the error is raised
immediately. See `Santati.new/1` for the `timeout_ms`, `max_retries`,
`initial_backoff_ms` and `max_backoff_ms` options.
## License
Apache-2.0. See `LICENSE`.