santati

Official Elixir SDK for the Santati 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:

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

Quickstart

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

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

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

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