Legal

API Reference

Last updated: 2026-06-02

This page documents the public API surface for the API tier. The API is also available to Enterprise customers under contract.

Authentication

Send a long-lived bearer token in the Authorization header:

Authorization: Bearer lk_live_<token>

Mint tokens at /dashboard/api-keys. The plaintext token is shown exactly once at creation time and never displayed again — store it in your secret manager immediately. We store only an argon2 hash plus a 12-character prefix for dashboard display.

Minting an API key requires interactive re-authentication within the last 5 minutes. An API key cannot mint another API key (the management routes are JWT-only).

Key lifecycle

Keys default to a 365-day expiry, customer- overridable up to 730 days at mint time. New keys cannot be issued with no expiry. Pre-existing keys without an expiry continue to work indefinitely until you rotate or revoke them.

Rotate a key with POST /v1/api-keys/&lbrace;id&rbrace;/rotate. The response returns a new plaintext token and starts a 7-day grace window on the old key — both keys authenticate during the window so you can swap out the deployed credential without an auth blackout. After the grace window expires, the old key is rejected.

Revocation via DELETE /v1/api-keys/&lbrace;id&rbrace; propagates across cluster-wide caches within 30 seconds. Plan retries / customer-facing SLAs around that bound.

Idempotency

Send an Idempotency-Key header on POST /v1/redact/async to make submission retries safe. The key is opaque to us — any string of 1..255 characters from [A-Za-z0-9_-] works; UUIDs and ULIDs are convenient choices.

A retry with the same key and same request body returns the originally cached 202 response (the response carries X-Lacuna-Idempotency-Replay: true on the cached path). A retry with the same key but a different body returns 409 Conflict with a stable detail prefix idempotency_key_payload_mismatch — this is the defence against a leaked key replayed against a different payload.

Cache entries are scoped to your organisation and expire after 24 hours. Future endpoints may opt into the same header; we'll add them here as they ship.

Rate limits

Rate limits return 429 Too Many Requests with a Retry-After header in seconds. Per-tier monthly page caps return the same status with a stable detail string and X-Lacuna-Upgrade-URL pointer when the cap is the cause.

Tenant isolation

Every key is scoped to the org that minted it. A 404 on any management route may indicate a typo OR a cross-tenant probe — the response shape is identical either way. Cross-tenant attempts are recorded in the org's audit chain (event type key.cross_tenant_attempt) so you can review them in the dashboard.

Audit log

Every authenticated mutation is recorded in a hash-chained tamper-evident audit log. Customer-visible events are available via the dashboard timeline and GET /v1/users/me/data-export. The full chain is verified daily; any inconsistency surfaces in the dashboard with the chain head hash you can quote for support.

Webhooks

Configure HTTPS endpoints at /dashboard/api-keys to receive events as they fire. We support two event types in v1: job.completed and job.quarantined. The delivery body never contains a download URL or any redacted content — fetch the artifact from GET /v1/jobs/&lbrace;id&rbrace; with your API key after you receive the event.

The request body is JSON:

{
  "event_id": "5f9c…",          // stable across retries — dedupe on this
  "event_type": "job.completed",
  "occurred_at": "2026-06-01T12:00:00+00:00",
  "data": { "job_id": "…", "status": "completed", "page_count": 12, "completed_at": "…" }
}

Each request carries a Lacuna-Signature header so you can verify it came from us:

Lacuna-Signature: t=<unix>,v1=<hex>[,v0=<hex>]

The signature is HMAC-SHA256 over the bytes f"&lbrace;t&rbrace;." + body keyed by your endpoint's signing secret (shown exactly once when you create or rotate the endpoint). Reject any request whose t is more than 5 minutes from your clock — that is the replay window. During a secret rotation's 7-day grace window we sign with both the new secret (v1) and the old (v0); accept either so you can swap your stored secret without dropping events.

A minimal Python verifier:

import hmac, hashlib, time

def verify(secret: str, header: str, body: bytes, max_age: int = 300) -> bool:
    fields = dict(part.split("=", 1) for part in header.split(","))
    t = int(fields["t"])
    if abs(time.time() - t) > max_age:        # replay window
        return False
    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + body, hashlib.sha256
    ).hexdigest()
    # Accept v1 (current) or v0 (rotating-out) during a grace window.
    return any(
        v in fields and hmac.compare_digest(expected, fields[v])
        for v in ("v1", "v0")
    )

Respond 2xx to acknowledge. A 4xx (other than 408 / 429) is treated as a permanent config error and the delivery is dropped. A 5xx, 408, 429, timeout, or connection failure is retried with exponential backoff at 15s, 1m, 5m, 25m, 2h; after the last retry the delivery is dead-lettered. Every outcome is recorded in your audit chain (webhook.delivered, webhook.retry, webhook.client_error, webhook.dead_lettered). Design your receiver to be idempotent on event_id.

Webhook secret storage

Webhook signing secrets are per-endpoint, so they live in our application database rather than a platform secret store. We store them envelope-encrypted: each secret is sealed with its own AES-GCM data key, which is in turn sealed by a key-encryption key held outside the database and injected only at runtime. A database-only compromise yields ciphertext, not your secret. This is a defence-in-depth measure, not a guarantee against all forms of compromise — rotate a secret with POST /v1/webhooks/&lbrace;id&rbrace;/rotate if you suspect it has leaked, and we'll sign with both the old and new secret for a 7-day grace window.

API questions
[email protected]