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:
| Credential | Prefix | Where | Can do |
|---|---|---|---|
| Secret key | stl_sk_… | Server only, Authorization: Bearer header | Ingest, read events/actors/sessions/stats, export, mint feed tokens |
| Public key | stl_pk_… | Browser, in the batch JSON body | POST /v1/events/batch only — cannot read anything (403) |
| Feed token | stl_ft_… | Browser, Authorization: Bearer header | Read one actor's events and sessions, or one tenant's events; cannot ingest, export, or read stats |
# 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
| Endpoint | Auth | Description |
|---|---|---|
POST/v1/events | secret | Ingest one event. Returns 201 with the event's UUID. |
POST/v1/events/batch | public (in body) | Ingest 1–100 auto-capture events in one call. Returns { accepted, rejected }. |
GET/v1/events | secret · feed token | List events, newest first, cursor-paginated. See Filtering for all query parameters and the response shape. |
GET/v1/events/export | secret | ?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/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…" }| Field | Required | Constraints |
|---|---|---|
actor.id | yes | ≤ 255 chars; name, email, avatar_url optional |
action | yes | ≤ 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_id | no | ≤ 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. |
target | no | type, id, name — all optional |
metadata | no | JSON 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_mode | no | manual (default) or auto |
session_id | no | Any string ≤ 255 chars (UUID preferred; other strings map to a stable UUID). Creates/extends a session automatically. |
timestamp | no | ISO 8601; defaults to now. Offset-less values are UTC; more than 5 minutes in the future → 422. Stored as occurred_at. |
context | no | ip_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/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
| Endpoint | Auth | Description |
|---|---|---|
POST/v1/feed-tokens | secret | Mint 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-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
| Endpoint | Auth | Description |
|---|---|---|
GET/v1/actors | secret · feed token | List 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 token | Fetch one actor by your external ID. |
Sessions
Sessions are created automatically from the session_id on any event — auto-capture always sends one.
| Endpoint | Auth | Description |
|---|---|---|
GET/v1/sessions | secret · feed token | List 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 token | Fetch one session's summary. For its event timeline call GET /v1/events?session_id={id}. |
Stats
| Endpoint | Auth | Description |
|---|---|---|
GET/v1/stats | secret | ?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.
| Endpoint | Auth | Description |
|---|---|---|
POST/v1/erasures | secret | Body: 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/erasures | secret | { 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} | secret | One erasure, or 404 "Erasure not found". |
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.
| Endpoint | Auth | Description |
|---|---|---|
GET/v1/integrity/keys | none (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/status | secret | sealing (active · paused_no_key · misconfigured), seal_interval_seconds, key_id, pending_events, oldest_pending_created_at, latest_checkpoint, retention_cutoff. |
GET/v1/integrity/checkpoints | secret | ?after_seq=0&limit=100 (1–500), ascending: { checkpoints, has_more, next_after_seq }. |
GET/v1/integrity/checkpoints/latest | secret | The newest checkpoint — the value to pin. 404 "No checkpoints yet" before the first seal. |
GET/v1/integrity/checkpoints/{seq}/bundle | secret | Everything 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/verify | secret | Verify 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/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:
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…" }| Status | Meaning |
|---|---|
400 | Empty request body |
401 | Missing, malformed, revoked, or expired credential |
402 | Monthly 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. |
403 | Wrong credential type — e.g. a public key on a read endpoint, or a feed token on export/stats |
404 | Unknown actor, session, erasure or checkpoint |
409 | An Idempotency-Key is still in flight (a concurrent retry of the same request) |
422 | Validation error — e.g. malformed or reserved (softechlog.*) action, metadata over 4 KB or with non-finite numbers, more than 100 events in a batch |
429 | Rate 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). |
429, 5xx), timeouts, and error swallowing for you — use the raw API only when an SDK doesn't fit.