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.

Pin an emit to one published schema version of its action with the event's schema_version (an integer, forwarded unchanged):

Santati.Events.emit(client, %{event: "invoice.voided", schema_version: 2})

Schemas

Santati.Schemas manages event definitions, their JSON Schema versions and the standard packs. Every function takes the client first and answers {:ok, value} or {:error, exception}:

{:ok, _definition} = Santati.Schemas.create_definition(client, %{action: "invoice.voided"})
{:ok, draft} = Santati.Schemas.create_version(client, "invoice.voided", %{"type" => "object"})

{:ok, _updated} =
  Santati.Schemas.update_version(
    client,
    "invoice.voided",
    draft.schema_version.version,
    %{"type" => "object"},
    if_match: draft.etag
  )

{:ok, _published} = Santati.Schemas.publish_version(client, "invoice.voided", draft.schema_version.version)

create_version/3 is sent once, because a repeat would create a second draft.

Outbox

Without an outbox, Santati.Events.emit/2 sends the event. Pass outbox: — a Santati.Outbox process, by pid or registered name — to Santati.new/1 and emit/2 is fire-and-forget: it validates the event, stores it in the outbox and answers {:ok, %Santati.EmitResult{queued: true, event: nil}} with the idempotency key. The Santati.Outbox process sends the outbox in batches in the background; pre_send and post_send hooks observe every event, and stopping the process flushes it.

{:ok, _pid} = Santati.Outbox.start_link(client: client, name: MyApp.Santati)
{:ok, client} = Santati.new(api_key: "sat_sk_...", trail: "billing", outbox: MyApp.Santati)

{:ok, %Santati.EmitResult{queued: true}} =
  Santati.Events.emit(client, %{event: "invoice.voided"})

Santati.Outbox.stop(MyApp.Santati)

Events are held in memory by default. Stopping the outbox sends each batch once; if the endpoint is down, what it could not send stays in the store and, in memory, is lost with the process. For a durable outbox use the Redis adapter: add {:redix, "~> 1.5"} to your dependencies and pass store: {Santati.Outbox.Redis, conn: redix_conn}.

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 (any code but schema_validation_failed)
Santati.SchemaValidationError HTTP 400, 413, 422 whose code is schema_validation_failed: the event broke the action's schema, named a disallowed target type, or pinned an unusable schema_version
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

An Elixir exception cannot subclass another, so %Santati.ValidationError{} does not match a Santati.SchemaValidationError: match both when you handle validation failures.

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

An event JSON cannot represent (a tuple, a pid or a reference anywhere in it) is a bug in the caller, not a failed request: emit/2 and emit_batch/2 raise Protocol.UndefinedError, and so does a queued Santati.Events.emit/2 when the bad term is a map key. A bad value is accepted by a queued emit/2 with the memory store; the outbox then reports its batch to post_send as Santati.OutboxError hook_failed and drops it. The Redis store refuses it in a queued emit/2 as Santati.OutboxError store_unavailable.

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.