Api Reference
TamperTrail — API Reference
Base URL: http://your-host (all traffic enters via Nginx on port 80)
API prefix: Every endpoint starts with /v1
Table of Contents
- Authentication
- Log API —
POST /v1/log - Health Check —
GET /health - HTTP Status Code Reference
- Rate Limits
1. Authentication
TamperTrail uses two separate authentication systems. Never mix them up.
API Key — For Sending Logs (this is what you need)
Create a key in the dashboard under API Keys → Create Key. The raw key is shown only once.
Pass it as an HTTP header on every request:
X-API-Key: vl_a1b2c3d4e5f6...
Keys are stored Argon2-hashed. A lost key cannot be recovered — revoke it and create a new one.
Never hardcode an API key. Use an environment variable:
TAMPERTRAIL_API_KEY=vl_...
JWT Session — For the Dashboard
The browser dashboard handles this automatically via an HTTPOnly cookie (tampertrail_token) set at login. Tokens expire after 24 hours. One active session per user — logging in from a new location ends the previous session. You don't need to worry about this for log integration.
2. Log API — POST /v1/log
Purpose: Ingest a single audit log entry.
Auth: X-API-Key header
Response: 202 Accepted — log is written to the WAL on disk and queued for batch DB insertion. The response does not wait for the DB write.
2.1 Payload Schema
Every log entry sent to TamperTrail can contain up to 12 fields. Only 2 are required.
| Field | Required | Type | Constraint | Description |
|---|---|---|---|---|
actor | YES | string | max 255 chars | Who performed the action. Use a consistent prefix format: "user:alice@acme.com", "service:billing-worker", "cron:nightly-sync" |
action | YES | string | max 255 chars | What happened. Use "resource.verb" convention: "invoice.created", "user.login.failed", "file.deleted" |
level | no | string | one of: DEBUG, INFO, WARN, ERROR, CRITICAL | Explicit severity. Case-insensitive. Overrides auto-detection. If omitted, TamperTrail derives it from keywords in action (see Severity Auto-Derivation). |
message | no | string | max 1,000 chars | Human-readable description of the event. This is the first thing someone reads when investigating a log. |
target_type | no | string | max 255 chars | Type of resource affected. Appears in the "Target" column: "order", "invoice", "user", "document" |
target_id | no | string | max 255 chars | Unique ID of that resource: "ORD-1001", "inv_9f2a3b4c", "usr_alice_8821" |
status | no | string | max 50 chars | Outcome. Accepts HTTP codes ("200", "404") or descriptive strings ("success", "failed", "timeout"). Default: "200" |
environment | no | string | max 100 chars | Deployment environment. Default: "production". Options: "production", "staging", "test", "development" |
source_ip | no | string | valid IPv4/IPv6 | End-user's IP address. If omitted, TamperTrail uses the API caller's IP automatically (respects X-Forwarded-For from Nginx). |
request_id | no | string | max 255 chars | Your trace/correlation ID. Also used as an idempotency key — duplicate request_id within 10 minutes is silently dropped. |
tags | no | object | any JSON object | Searchable, plaintext key-value pairs. GIN-indexed JSONB. Visible in the dashboard. Use for data you want to filter and display. |
metadata | no | object | any JSON object | Encrypted key-value payload. Fernet AES-128 encrypted on arrival. Never returned by the API. Never shown in the UI. Use for PII, credentials, stack traces. |
Field-by-Field Examples
actor — who did it:
"user:alice@acme.com" — a logged-in user
"service:billing-worker" — a backend service/microservice
"cron:nightly-sync" — a scheduled job
"api:mobile-app" — an API consumer
"admin:superadmin" — an admin performing a privileged action
action — what happened:
"order.created" — resource.verb format
"payment.refunded" — financial event
"user.login.failed" — nested context
"file.deleted" — destructive action (auto-detected as critical)
"api_key.revoked" — security event
message — write for humans:
✅ "Order ORD-1001 created — AirPods Pro worth ₹24,900 shipping from Mumbai to Delhi"
✅ "Payment of $149.00 processed via Stripe for Pro plan upgrade"
❌ "order created" — too vague, not useful for investigation
❌ "{json blob here}" — put structured data in tags/metadata, not message
target_type + target_id — identify the resource:
target_type: "order" target_id: "ORD-1001"
target_type: "invoice" target_id: "inv_9f2a3b4c"
target_type: "user" target_id: "usr_alice_8821"
target_type: "document" target_id: "doc_annual_report_2025"
target_type: "api_key" target_id: "vl_a1b2"
status — what happened:
"success" — action completed
"failed" — action did not complete
"timeout" — action timed out
"200" — HTTP 200 OK
"404" — resource not found
"500" — server error
environment — deployment context:
"production" — live system
"staging" — pre-production testing
"test" — automated test environment
"development" — local development
2.2 The tags vs. metadata Decision
This is the most important design decision when integrating TamperTrail.
tags | metadata | |
|---|---|---|
| Storage | Plaintext JSONB | Fernet AES-128 encrypted BYTEA |
| Searchable | ✅ Yes — GIN-indexed, fast | ❌ No — encrypted blob |
| Visible in dashboard | ✅ Yes — shown in Tags column | ❌ Never — not in any UI |
| Returned by API | ✅ Yes — in GET /v1/logs | ❌ Never — no API returns it |
| Use for | Context you want to filter and display | Sensitive forensic data |
Decision rule — ask yourself:
- Would you be comfortable showing this on a team dashboard? →
tags - Does it contain PII, secrets, credentials, stack traces, or internal details? →
metadata
tags examples (searchable, visible):
{
"payment_provider": "stripe",
"amount_usd": "149.00",
"plan": "pro",
"region": "eu-west-1",
"browser": "Chrome"
}
metadata examples (encrypted, never shown):
{
"card_last4": "4242",
"stripe_charge": "ch_3abc123def",
"billing_email": "alice@acme.com",
"full_request_body": { "..." },
"stack_trace": "Traceback (most recent call last)..."
}
2.3 Severity Auto-Derivation
If you don't provide a level field, TamperTrail automatically derives severity by scanning your action string for keywords.
How level maps to dashboard severity:
level you send | Dashboard badge | Color |
|---|---|---|
DEBUG | info | blue |
INFO | info | blue |
WARN | warning | amber |
ERROR | critical | red |
CRITICAL | critical | red |
When level is omitted — auto-detection kicks in:
TamperTrail scans the action string for these keywords:
| Auto-derived severity | Triggered when action contains any of... |
|---|---|
critical (red) | delete, destroy, revoke, drop, purge, wipe |
warning (amber) | update, edit, modify, change, patch, rename |
info (blue) | everything else |
Examples:
action: "user.deleted" → auto-derived: critical (contains "delete")
action: "profile.updated" → auto-derived: warning (contains "update")
action: "order.created" → auto-derived: info (no trigger words)
action: "api_key.revoked" → auto-derived: critical (contains "revoke")
Explicit
levelalways wins. If you send"level": "INFO"with"action": "user.deleted", the log will beinfo, notcritical.
2.4 Auto-Captured Fields
These fields are captured by the server automatically. You do not need to provide them.
| Field | Source | Description |
|---|---|---|
source_ip | HTTP connection | Client IP address. Reads X-Forwarded-For from Nginx. You can override it by passing source_ip in the body. |
user_agent | User-Agent header | Raw user-agent string of the API caller (e.g. "python-httpx/0.27", "Mozilla/5.0 Chrome/...") |
device_type | Parsed from user-agent | Automatically classified: "desktop", "mobile", "tablet", "bot", or null |
created_at | Server clock (UTC) | Timestamp of ingestion. Clock-skew protected — always monotonically increasing per tenant. |
id | Server-generated | UUID v4, unique identifier for this log entry |
hash | Server-computed | SHA-256 hash of this entry, chained from the previous entry's hash |
2.5 Requests & Responses
Minimal Request — Only the 2 required fields
{
"actor": "user:alice@acme.com",
"action": "document.downloaded"
}
Full Request — Every field TamperTrail accepts
{
"actor": "user:alice@acme.com",
"action": "payment.processed",
"level": "INFO",
"message": "Payment of $149.00 processed via Stripe for Pro plan upgrade.",
"target_type": "invoice",
"target_id": "inv_9f2a3b4c",
"status": "success",
"environment": "production",
"source_ip": "203.0.113.42",
"request_id": "req_trace_abc123",
"tags": {
"payment_provider": "stripe",
"amount_usd": "149.00",
"plan": "pro"
},
"metadata": {
"card_last4": "4242",
"stripe_charge": "ch_3abc123def",
"billing_email": "alice@acme.com"
}
}
Success Response
{ "status": "accepted", "message": "Log queued for processing" }
Error Responses
| Code | Cause |
|---|---|
401 | Missing or invalid X-API-Key |
403 | API key is revoked |
422 | actor or action missing, or any field exceeds its max length |
429 | Rate limit exceeded — 100 req/min per IP |
503 | WAL disk full. Free disk space and retry. |
2.6 Integration Examples
While you can write standard HTTP requests directly to /v1/log, we recommend using our official drop-in snippets to ensure connection pooling and non-blocking execution.
3. Health Check — GET /health
Purpose: Returns the live status of the server, database, and ingestion queue. No authentication required. Suitable for load balancer health probes.
Auth: None
Response: 200 OK
{
"status": "ok",
"db": "ok",
"queue_depth": 0,
"wal_entries": 0
}
If the database is unreachable, "db" will be "error" and the status code will be 503.
4. HTTP Status Code Reference
| Code | Meaning | Common Cause |
|---|---|---|
200 OK | Success | Standard successful response |
201 Created | Resource created | New user, key, or checkpoint |
202 Accepted | Queued | Log entry accepted by WAL, not yet written to DB |
400 Bad Request | Invalid request logic | Business rule violation (e.g. deleting yourself) |
401 Unauthorized | Auth failed | Missing/invalid API key or wrong password |
403 Forbidden | Access denied | Revoked key, deactivated account, or license limit |
404 Not Found | Resource missing | User/key with given ID does not exist |
409 Conflict | Duplicate | Setup already done, username already exists |
422 Unprocessable Entity | Schema error | Missing required field, field too long, bad enum value |
429 Too Many Requests | Rate limited | Exceeded per-IP request quota (see Rate Limits) |
503 Service Unavailable | Server error | Database unreachable, or WAL disk full |
5. Rate Limits
Rate limiting is enforced by Nginx before requests reach the application server. Limits are per IP address.
| Endpoint(s) | Limit | Burst |
|---|---|---|
POST /v1/auth/login | 5 req/min | 3 |
POST /v1/log | 100 req/min | 20 |
All other /v1/* | 200 req/min | 50 |
When a limit is exceeded, Nginx returns 429 Too Many Requests with a Retry-After header.
For high-volume ingestion above 100 req/min, batch your logs client-side into arrays and send them in fewer, larger requests — or contact us about a Pro ingestion upgrade.
Help improve these docs
See a typo or outdated information? Open a GitHub issue and we'll update it.
View Raw Markdown on GitHub