Docs

API Reference

Everything the SDKs do under the hood. Base URL: https://api.softechlog.com — all endpoints are JSON over HTTPS.

Authentication

Three credentials, each with a deliberately narrow job:

CredentialPrefixWhereCan do
Secret keystl_sk_…Server only, Authorization: Bearer headerIngest, read events/actors/sessions/stats, export, mint feed tokens
Public keystl_pk_…Browser, in the batch JSON bodyPOST /v1/events/batch only — cannot read anything (403)
Feed tokenstl_ft_…Browser, Authorization: Bearer headerRead one actor's events and sessions, or one tenant's events; cannot ingest, export, or read stats
HeadersBearer auth
# Server-side — secret key (ingest + read + export + stats + mint feed tokens)
Authorization: Bearer stl_sk_xxxxxxxxxxxx

# Browser — feed token (read-only, pinned to an actor and/or a tenant; minted with a secret key)
Authorization: Bearer stl_ft_xxxxxxxxxxxx

# Browser — public key (auto-capture ONLY; travels in the batch JSON body, not a header)
{ "public_key": "stl_pk_xxxxxxxxxxxx", "events": [ … ] }

Customer sites can call the batch endpoint and read with feed tokens from any origin (CORS Allow-Origin: *, no credentials); everything else is restricted to the dashboard's origins.

Events

EndpointAuthDescription
POST/v1/eventssecretIngest one event. Returns 201 with the event's UUID.
POST/v1/events/batchpublic (in body)Ingest 1–100 auto-capture events in one call. Returns { accepted, rejected }.
GET/v1/eventssecret · feed tokenList events, newest first, cursor-paginated. See Filtering for all query parameters and the response shape.
GET/v1/events/exportsecret?format=csv|json plus the same filters as the list endpoint. Up to 10,000 rows, Content-Disposition: attachment. Available on all plans.

Ingest an event

POST /v1/eventsrequest / response
POST /v1/events
Content-Type: application/json
Authorization: Bearer stl_sk_xxxxxxxxxxxx

{
  "actor":     { "id": "user_123", "name": "Ari", "email": "ari@acme.co", "avatar_url": null },
  "action":    "member.invited",
  "tenant_id": "org_acme",
  "target":    { "type": "workspace", "id": "ws_abc", "name": "Acme" },
  "metadata":  { "role": "admin" },
  "capture_mode": "manual",
  "session_id": "0f6d5c1e-…",
  "timestamp": "2026-08-17T14:09:31Z",
  "context":   { "ip_address": "203.0.113.4", "user_agent": "Mozilla/5.0 …" }
}

→ 201 { "id": "9b2f0f5e-4c1a-4e7b-9d3f-2a6c8e1b7f10", "queued_at": "2026-08-17T14:09:31.412Z",
        "leaf_hash": "5d1c…64 hex…" }
FieldRequiredConstraints
actor.idyes≤ 255 chars; name, email, avatar_url optional
actionyes≤ 200 chars, must match ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$ — otherwise 422. The softechlog. namespace is reserved for events Softechlog writes itself (e.g. softechlog.actor.erased) → 422 "the 'softechlog.' action namespace is reserved".
tenant_idno≤ 255 chars. Your customer's account / org / workspace id — lets that customer see everything that happened in their account (tenant feed tokens, ?tenant_id= filter, export). Secret key only.
targetnotype, id, name — all optional
metadatanoJSON object ≤ 4 KB. Numbers must be finite (NaN/Infinity → 422). Two normalisations are applied before storage so the stored value hashes the same after a database round trip: -0.0 becomes 0.0, and whole-number floats of magnitude ≥ 1016 become integers. Nothing else changes (100.0 stays 100.0).
capture_modenomanual (default) or auto
session_idnoAny string ≤ 255 chars (UUID preferred; other strings map to a stable UUID). Creates/extends a session automatically.
timestampnoISO 8601; defaults to now. Offset-less values are UTC; more than 5 minutes in the future → 422. Stored as occurred_at.
contextnoip_address, user_agent of the end user; otherwise the request's own are recorded

Send an Idempotency-Key header (any unique string ≤ 128 chars, e.g. a UUID) and reuse it when you retry: a repeat within 24 hours returns the original event with 200 and Idempotent-Replayed: true instead of storing a duplicate. The official SDKs do this automatically.

The response's leaf_hash is the event's integrity leaf — the SHA-256 that the next checkpoint seals (see Tamper evidence). Store it for high-value events: it is your receipt for the minutes before the event is sealed. Idempotent replays return the same value.

Events returned by GET /v1/events and the actor history include erasure_id: null, or the id of the erasure request that last redacted the row. Exports (CSV and JSON) append an erasure_id column as the last column, so positional parsers keep working.

Batch ingest (auto-capture)

POST /v1/events/batchrequest / response
POST /v1/events/batch
Content-Type: text/plain          # no CORS preflight; the body is still JSON

{
  "public_key": "stl_pk_xxxxxxxxxxxx",
  "events": [
    {
      "action": "ui.page_viewed",
      "actor": { "id": "user_123" },
      "metadata": { "url": "https://app.acme.co/settings", "title": "Settings" },
      "capture_mode": "auto",
      "session_id": "0f6d5c1e-…",
      "occurred_at": "2026-08-17T14:09:31Z"
    }
    /* 1 – 100 events */
  ]
}

→ 201 { "accepted": 1, "rejected": 0 }

Each item accepts action (required), actor, target, metadata, session_id, and occurred_at. Everything a public key sends is stored as capture_mode: "auto" (a capture_mode field is ignored), occurred_at is clamped to the server's clock, and actor identity fields only fill in blanks — they never overwrite what the secret key recorded. tenant_id is ignored here: anyone can read a public key from your page source, so only your server may place events in an account's audit trail. Items with an invalid or reserved (softechlog.*) action, oversized metadata, non-finite metadata numbers, or an occurred_at older than a year are counted in rejected; the rest are stored. The whole batch is refused with 402 if it would exceed your monthly cap.

Feed tokens

EndpointAuthDescription
POST/v1/feed-tokenssecretMint a read-only token pinned to an actor_id (one user's activity), a tenant_id (every event in that account), or both (one user within the account) — at least one is required. Optionally also target_type/target_id. ttl_seconds 60–86400, default 3600. Tenant-only tokens read GET /v1/events only; /v1/actors and /v1/sessions answer 403.
POST /v1/feed-tokensrequest / response
POST /v1/feed-tokens
Authorization: Bearer stl_sk_xxxxxxxxxxxx

{ "actor_id": "user_123", "ttl_seconds": 3600 }          # one user
{ "tenant_id": "org_acme", "ttl_seconds": 3600 }         # a whole account

→ 201 { "token": "stl_ft_…", "expires_at": "2026-08-17T15:09:31Z", "actor_id": "user_123", "tenant_id": null }

Actors

EndpointAuthDescription
GET/v1/actorssecret · feed tokenList identified users: { actors: [{ id, external_id, name, email, avatar_url, first_seen_at, last_seen_at, total_events }], has_more, next_cursor }. Supports search (name, email or external id), limit, cursor. Feed tokens see only their own actor.
GET/v1/actors/{external_id}secret · feed tokenFetch one actor by your external ID.

Sessions

Sessions are created automatically from the session_id on any event — auto-capture always sends one.

EndpointAuthDescription
GET/v1/sessionssecret · feed tokenList sessions: { sessions: [{ id, actor, started_at, ended_at, event_count, starting_page }], has_more, next_cursor }. Supports actor_id, from, to, limit, cursor.
GET/v1/sessions/{id}secret · feed tokenFetch one session's summary. For its event timeline call GET /v1/events?session_id={id}.

Stats

EndpointAuthDescription
GET/v1/statssecret?days=14 (1–90). Headline totals, a per-day series, top actions, and current plan usage — the same numbers the dashboard Overview shows.

Erasures

Erase one of your users' personal data (GDPR Art. 17). What each mode keeps and removes is spelled out in Erasure. Secret key, or the dashboard; public keys and feed tokens get 403.

EndpointAuthDescription
POST/v1/erasuressecretBody: actor_id (required, ≤ 255), mode delete (default) · redact, target_types (0–10 strings, default ["user"]; [] leaves mentions alone), reference (≤ 200, no personal data). 201 when it finished inside the request; 202 when it is finishing in the background, or when an erasure for the same user is already in flight (the body is that one). Optional Idempotency-Key: a replay returns 200 with Idempotent-Replayed: true and the first response (possibly stale — GET it for fresh status); 409 while the first call is still running.
GET/v1/erasuressecret{ erasures, has_more, next_cursor }, newest first. Filters: actor_id (matched by keyed hash — the plaintext is never stored), status (pending · running · completed · failed), limit 1–100 (default 25), cursor.
GET/v1/erasures/{id}secretOne erasure, or 404 "Erasure not found".
POST /v1/erasuresrequest / response
POST /v1/erasures
Authorization: Bearer stl_sk_xxxxxxxxxxxx
Idempotency-Key: 5a0c…                     # optional, 24 h replay

{ "actor_id": "user_123", "mode": "delete", "target_types": ["user"], "reference": "DSR-2026-0142" }

→ 201 (finished) or 202 (finishing in the background)
{
  "id": "7c1e…", "status": "completed", "mode": "delete", "target_types": ["user"],
  "reference": "DSR-2026-0142", "requested_via": "api_key", "requested_by": "stl_sk_ab12cd34",
  "requested_at": "2026-09-26T10:00:00.123456+00:00", "started_at": "…", "completed_at": "…",
  "actor_found": true,
  "counts": { "events_deleted": 212, "events_redacted": 0, "target_refs_redacted": 3, "sessions_deleted": 9 },
  "affected_count": 215, "affected_hash": "64 hex", "evidence_event_id": "…", "error": null
}

The Erasure object is the same in every response and never contains the user's id. actor_found is null until completion; affected_hash (SHA-256 over the affected event ids and codes) and evidence_event_id (the sealed softechlog.actor.erased event) are set on completion; error is an exception class name when failed.

Tamper evidence

Checkpoints, keys and verification for the tamper-evident log. All integrity timestamps are UTC strings with exactly six fractional digits and a trailing Z (the exact strings that were signed); all hashes are lowercase hex.

EndpointAuthDescription
GET/v1/integrity/keysnone (public){ algorithm: "Ed25519", keys: [{ key_id, public_key, status }] } — public_key is standard base64 of the raw 32 bytes; key_id is the first 16 hex chars of its SHA-256; status current · retired. Cached for 5 minutes.
GET/v1/integrity/statussecretsealing (active · paused_no_key · misconfigured), seal_interval_seconds, key_id, pending_events, oldest_pending_created_at, latest_checkpoint, retention_cutoff.
GET/v1/integrity/checkpointssecret?after_seq=0&limit=100 (1–500), ascending: { checkpoints, has_more, next_after_seq }.
GET/v1/integrity/checkpoints/latestsecretThe newest checkpoint — the value to pin. 404 "No checkpoints yet" before the first seal.
GET/v1/integrity/checkpoints/{seq}/bundlesecretEverything needed to verify one checkpoint offline: checkpoint, prev, head, leaves_b64 (null once pruned), the still-present events with their salts and committed parts, and the erased items. Contains IP addresses and user agents — your own data, so keep it behind the same controls as an export.
GET/v1/integrity/events/{event_id}secret{ event_id, status: "sealed" | "pending" | "unsealed", seq, leaf_hash, checkpoint_hash }. unsealed is itself an anomaly.
GET/v1/integrity/verifysecretVerify a range: from_seq, to_seq (default the last 50 checkpoints; at most 50 per call and 100,000 leaves — follow next_from_seq), pin=<seq>:<hash> (repeatable, up to 10), expected_retention_days. ok is true exactly when failures is empty.
GET /v1/integrity/verifyresponse
GET /v1/integrity/verify?pin=41:9f2c…&expected_retention_days=90
Authorization: Bearer stl_sk_xxxxxxxxxxxx

→ 200
{
  "ok": true, "project_id": "…", "from_seq": 1, "to_seq": 42, "head_seq": 42, "head_hash": "64 hex",
  "next_from_seq": null, "checkpoints_checked": 42, "leaves_checked": 12000,
  "events": { "verified": 11900, "redacted": 12, "expired": 80, "erased": 8, "pending_seal": 3 },
  "retention_cutoff": "2026-06-28T00:00:00.000000Z", "retention_days": 90,
  "failures": [], "failures_truncated": false,
  "warnings": [], "warnings_truncated": false,
  "verified_at": "2026-09-26T10:00:00.000000Z"
}

Failure kinds: header_hash_mismatch, bad_signature, unknown_key, seq_gap, chain_break, retention_cutoff_invalid, retention_below_expected, pinned_mismatch, pinned_missing, leaves_missing, leaves_length_mismatch, leaves_hash_mismatch, event_modified, event_missing, redaction_unexplained, unexpected_event, event_unsealed, erasure_evidence_missing, erasure_evidence_mismatch. Warning kinds: retention_days_changed, erasure_in_progress, erasure_failed, sealing_stalled, sealing_paused, retention_cutoff_unsigned, no_checkpoints. Each list is capped at 100 entries (failures_truncated / warnings_truncated).

Errors & limits

Every error is JSON with a detail and a request_id you can quote to support:

Error responseapplication/json
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json

{ "detail": "Rate limit exceeded: 1000 requests per 60s.", "request_id": "3f9c…" }
StatusMeaning
400Empty request body
401Missing, malformed, revoked, or expired credential
402Monthly plan limit reached — on single and batch ingest alike. Nothing is deleted; reads keep working; ingest resumes on the 1st of next month or when you upgrade.
403Wrong credential type — e.g. a public key on a read endpoint, or a feed token on export/stats
404Unknown actor, session, erasure or checkpoint
409An Idempotency-Key is still in flight (a concurrent retry of the same request)
422Validation error — e.g. malformed or reserved (softechlog.*) action, metadata over 4 KB or with non-finite numbers, more than 100 events in a batch
429Rate limited — 1,000 requests/min per secret key on ingest, 200 batch calls/min per public key, 600 reads/min per credential, 60 erasure requests/min per project, 10 verify/bundle calls/min per project, 120 key lookups/min per IP. Also returned when a project already has 1,000 erasures in progress ("Too many erasures in progress; retry later."). Includes a Retry-After header (seconds).
The official SDKs handle retries (network errors, 429, 5xx), timeouts, and error swallowing for you — use the raw API only when an SDK doesn't fit.