Skip to content

API Reference

Complete reference for the Diogenes REST API. All endpoints are prefixed with /api/v1.

Interactive API Explorer

Try the API interactively using the Swagger UI or the ReDoc viewer.


Health

GET /api/v1/health

Check server status.

Response:

{
  "status": "ok",
  "version": "0.1.0"
}

Authentication

Authentication uses a challenge-response protocol. The client requests a challenge nonce, signs it with their private key, and submits the signature to obtain a JWT.

POST /api/v1/auth/challenge

Request an authentication challenge.

Request body:

Field Type Required Description
fingerprint string Yes Key fingerprint to authenticate as
recovery boolean No Request a recovery challenge (default: false)
purpose string No Challenge purpose: "general" or "login" (default: "general")

Response:

{
  "challenge_id": "abc123...",
  "nonce": "random-nonce-value",
  "expires_at": "2026-01-15T10:35:00Z"
}

POST /api/v1/auth/login

Authenticate and receive a JWT.

Request body:

Field Type Required Description
challenge_id string Yes The challenge ID from /auth/challenge
challenge_signature string Yes Signature of the challenge nonce
password string No Password if one is registered for this key

Response:

{
  "token": "eyJ...",
  "fingerprint": "sha256:...",
  "pseudonym": "Alice Scholar",
  "expires_at": "2026-01-15T11:00:00Z"
}

Error codes: 404 Challenge not found, 410 Challenge expired, 409 Challenge already used, 403 Invalid signature or password.

POST /api/v1/auth/renew

Renew an existing JWT before it expires.

Headers: Authorization: Bearer <token>

Response: Same as /auth/login.

POST /api/v1/auth/password

Set or change a password for a key.

Request body:

Field Type Required Description
challenge_id string Yes Auth challenge ID
challenge_signature string Yes Signed challenge nonce
password string Yes New password to set
current_password string No Current password (required when changing)

Users

An account (user) owns one or more credentials, each binding a signing key to that account. Identity is decoupled from any single key so a device can be added or retired without losing the account.

POST /api/v1/users

Create an account together with its first credential, atomically. Unauthenticated by design — there is no prior credential to authenticate against.

Request:

{
  "display_name": "Alice Scholar",
  "email": "alice@example.org",
  "credential_type": "software",
  "key_fingerprint": "sha256:...",
  "device_label": "Laptop"
}

Response (201): user, credential, and recovery_key. The recovery key is returned exactly once and cannot be retrieved again.

POST /api/v1/users/recover

Recover an account with pseudonym + recovery_key. Revokes every active credential, enrolls the supplied replacement, and rotates the recovery key. Returns 401 on a wrong key or unknown pseudonym.

/api/v1/users/{user_id}/credentials — removed

All four verbs (GET, POST, PATCH, DELETE) were deleted in issue #502 (finding C-2 of the FedRAMP High security review, #200). They resolved to a database session and nothing else. Because a session's identity is resolved purely by the credential row matching the JWT fingerprint, binding your own key to another account's user_id made your own valid login resolve as that account — full takeover, reachable with no credentials at all. The unauthenticated PATCH renamed another user's device and the DELETE was a lockout primitive.

They were removed rather than authenticated. Binding a credential belongs to POST /api/v1/credentials/enroll, which does the job properly: signed challenge, active non-revoked issuing credential, verified chain signature, and hardware backing on both sides.

GET /api/v1/credentials/user/{user_id}/fingerprints — removed

Deleted in the same issue (finding L-15). It was unauthenticated on the same surface; its incremental disclosure over the already-public GET /api/v1/keys was the user-supplied device_label for any user_id. Removed rather than authenticated because no caller anywhere in the system used it.


Keys

GET /api/v1/keys

List all registered keys.

Response:

{
  "keys": [
    {
      "fingerprint": "sha256:...",
      "pseudonym": "Alice Scholar",
      "algorithm": "ed25519",
      "status": "active",
      "registered_at": "2026-01-15T10:00:00Z",
      "expires_at": null
    }
  ]
}

GET /api/v1/keys/search

Search keys by pseudonym.

Query parameters:

Parameter Type Description
pseudonym string Partial pseudonym match (case-insensitive)

Response: Same format as GET /api/v1/keys.

GET /api/v1/keys/{fingerprint}

Get details for a specific key.

Response:

{
  "fingerprint": "sha256:...",
  "public_key": "-----BEGIN PUBLIC KEY-----\n...",
  "pseudonym": "Alice Scholar",
  "algorithm": "ed25519",
  "status": "active",
  "registered_at": "2026-01-15T10:00:00Z",
  "expires_at": null,
  "log_entry_id": 1
}

GET /api/v1/keys/{fingerprint}/status

Quick key validity check (target: less than 500ms).

Response:

{
  "fingerprint": "sha256:...",
  "status": "active",
  "expires_at": null
}

POST /api/v1/keys/register

Register a new public key on the transparency log.

Request body:

{
  "payload": {
    "public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
    "pseudonym": "Alice Scholar",
    "key_algorithm": "ed25519",
    "expiration_date": "2027-01-15T00:00:00Z"
  }
}
Field Type Required Description
payload.public_key string Yes PEM-encoded public key
payload.pseudonym string Yes Display name for the key holder
payload.key_algorithm string Yes "ed25519", "ecdsa-p256", or "rsa-2048"
payload.expiration_date string No ISO 8601 expiration date

Response:

{
  "fingerprint": "sha256:...",
  "pseudonym": "Alice Scholar",
  "algorithm": "ed25519",
  "log_entry_id": 1,
  "leaf_index": 0,
  "entry_hash": "sha256:..."
}
Field Type Description
log_entry_id integer Autoincrement primary key of the log entry. Stable identifier for lookups.
leaf_index integer 0-based chronological position in the Merkle log. Use with GET /api/v1/log/proof/{leaf_index} to fetch an inclusion proof (issue #375).

POST /api/v1/keys/succeed

Replace an active key with a new one (key succession).

Request body:

{
  "payload": {
    "old_fingerprint": "sha256:...",
    "new_public_key": "-----BEGIN PUBLIC KEY-----\n...",
    "new_algorithm": "ed25519"
  },
  "auth": {
    "challenge_id": "...",
    "challenge_signature": "..."
  }
}

Accepts either JWT Bearer auth or legacy auth proof. For recovery succession of expired keys, include "recovery": true in the auth object.

Response:

{
  "old_fingerprint": "sha256:...",
  "new_fingerprint": "sha256:...",
  "log_entry_id": 3,
  "leaf_index": 2,
  "entry_hash": "sha256:..."
}

leaf_index is the 0-based Merkle position for the new succession entry (issue #375).

POST /api/v1/keys/revoke

Revoke an active key.

Request body:

{
  "payload": {
    "fingerprint": "sha256:..."
  },
  "auth": {
    "challenge_id": "...",
    "challenge_signature": "..."
  }
}

Response:

{
  "fingerprint": "sha256:...",
  "status": "revoked",
  "log_entry_id": 4,
  "leaf_index": 3,
  "entry_hash": "sha256:..."
}

leaf_index is the 0-based Merkle position for the revocation entry (issue #375).


Signing

POST /api/v1/attestations

Submit a client-signed attestation. The private key never leaves the client.

Request body:

Field Type Required Description
manifest object Yes Document manifest with content hash
public_key_pem string Yes PEM-encoded public key of the signer
signature string Yes Base64-encoded signature of the manifest
attestation_type string No "authorship", "editorial_review", "peer_review", "publication" (default: "authorship")
pseudonym string No Signer pseudonym (used if key not yet registered)
intent_statement string No Free-text statement of signing intent
password string No Password if registered for this key

Response:

{
  "attestation": {
    "id": "a1b2c3...",
    "type": "authorship",
    "scope": { "content_hash": "sha256:..." },
    "signer_key_fingerprint": "sha256:...",
    "signature_algorithm": "ed25519",
    "signature": "...",
    "timestamp": "2026-01-15T10:30:00Z"
  },
  "fingerprint": "sha256:...",
  "pseudonym": "Alice Scholar",
  "log_entry_id": 2,
  "leaf_index": 1
}
Field Type Description
log_entry_id integer Autoincrement primary key of the log entry. Stable identifier for lookups.
leaf_index integer 0-based chronological position in the Merkle log. Pass to GET /api/v1/log/proof/{leaf_index} to fetch an inclusion proof for offline-verification bundles (issue #375).

Error codes: 422 Invalid public key, attestation type, or manifest; signature verification failed. 403 Password required or invalid. 500 Log entry persisted but leaf_index could not be resolved (log_entry_id is also a 500 in this branch — both fields are required on the receipt).


Verification

POST /api/v1/verify

Verify a document given its manifest and source hash. Source content is never sent to the server.

Request body:

Field Type Required Description
manifest object Yes The document manifest
source_hash string Yes SHA-256 hex hash of the source document

Response:

{
  "layer1": { "status": "valid", "details": "..." },
  "layer2": { "status": "valid", "details": "..." },
  "overall_status": "valid",
  "attestation_graph": { "..." }
}

Error codes: 422 Invalid manifest. 503 Transparency log unreachable.


Transparency Log

GET /api/v1/log

Paginated log entries with optional filters.

Query parameters:

Parameter Type Default Description
page integer 1 Page number (1-indexed)
per_page integer 50 Entries per page (max 200)
event_type string -- Filter by event type (single, or comma-separated list)
tags string -- Comma-separated tags. Entries must carry all of them.
search string -- Prefix search across digests, hashes and fingerprints

Tag matching (changed in #456). A tag matches a whole element of the entry's tags array, case-insensitively. Previously the filter was a substring test over the serialised array, so ecosystem:go also matched entries tagged ecosystem:golang. One divergence from the in-memory log remains and is deliberate: leading/trailing whitespace inside a stored tag is not stripped, because payload is an input to the entry hash and so can never be rewritten by a backfill.

Search matching (changed in #456). Terms are matched as prefixes, not interior substrings:

  • A digest prefix matches any entry whose entry_hashes[*].digest_hex starts with the term (case-insensitive on input; digests are stored lowercase).
  • A complete hash term (64 or 128 hex characters, with or without a sha256: prefix) is matched exactly against document_hash, manifest_hash and the fingerprint payload fields, in both the bare and sha256:-prefixed stored forms. Pasting a bare hash now reliably finds an entry the signing pipeline stored prefixed, and vice versa.
  • Any other term is matched as a case-insensitive prefix of the same payload fields.

Searching by a hash tail or an interior fragment no longer matches.

The same tags semantics apply to GET /api/v1/log/audit-trail.

Response:

{
  "entries": [
    {
      "id": 1,
      "timestamp": "2026-01-15T10:00:00Z",
      "event_type": "key_registration",
      "payload": { "..." },
      "previous_hash": null,
      "entry_hash": "sha256:..."
    }
  ],
  "page": 1,
  "per_page": 50,
  "total": 1
}

GET /api/v1/log/head

Get the latest log entry and total count.

Response:

{
  "head": {
    "id": 42,
    "entry_hash": "sha256:...",
    "event_type": "attestation",
    "timestamp": "2026-01-15T10:30:00Z"
  },
  "entry_count": 42
}

GET /api/v1/log/{entry_id}

Get a single log entry by ID.

Response: Same format as entries in GET /api/v1/log.

Error codes: 404 Entry not found.

GET /api/v1/log/by-fingerprint/{fingerprint}

Get log entries referencing a specific fingerprint, one page at a time.

Matches six payload fields: fingerprint, old_fingerprint, signer_fingerprint, signer_key_fingerprint, endorsed_key_fingerprint, endorser_key_fingerprint. Entries are ordered by ascending log entry ID.

Query parameters:

Parameter Type Default Description
page int 1 1-based page number.
per_page int 50 Entries per page. Maximum 200.

Response:

{
  "entries": [ "..." ],
  "page": 1,
  "per_page": 50,
  "total": 137
}

Changed in #456. This endpoint previously returned every matching entry in a single unbounded response. It now defaults to 50 entries and reports page / per_page / total. Callers that relied on receiving the complete history in one response must page through it.

GET /api/v1/log/sth

Return the latest persisted Signed Tree Head (STH) for third-party audit. The server signs an STH on every log append (issue #294 Phase 2) and this endpoint returns the most recent row.

Response:

{
  "tree_size": 42,
  "root_hash": "sha256:...",
  "signature": "...",
  "timestamp": "2026-01-15T10:30:00Z",
  "key_id": "<operator-fingerprint>"
}

Historical STHs for past tree sizes are available at GET /api/v1/log/sth/{tree_size} (404 if the size predates the STH chain).

GET /api/v1/log/audit-trail

Query audit trail with combinable filters.

Query parameters:

Parameter Type Description
fingerprint string Filter by key fingerprint
document_hash string Filter by document or manifest hash
start_date string ISO 8601 start date
end_date string ISO 8601 end date

At least one parameter is required.

Response: Same format as GET /api/v1/log.

POST /api/v1/log/entries — removed

Deleted in issue #500 (finding C-1 of the FedRAMP High security review,

200). The endpoint was unauthenticated and unsigned: anyone could

hash-chain arbitrary content into the authoritative log under any event_type, and because a forged entry keeps the chain internally valid, POST /api/v1/log/verify could not distinguish it from a genuine one.

It was removed rather than authenticated. Every hardened writer — key registration, endorsements, attestations — appends through core/transparency_log_db.append_chained_log_entry internally, so the route added nothing but a second, public way into the same code. Use the domain endpoint for the event you are recording (for example POST /api/v1/attestations); there is no general-purpose append API.

Operators who need a Signed Tree Head at the current size use the operator-only POST /api/v1/log/sth/sign.

POST /api/v1/log/verify

Verify the integrity of the entire hash chain.

Response:

{
  "valid": true,
  "entry_count": 42,
  "errors": []
}

POST /api/v1/log/anchor

Anchor the latest log head via OpenTimestamps. Operator-only.

Requires Authorization: Bearer <token> where the token is a JWT signed by the operator key whose sub claim is the operator fingerprint. Ordinary session tokens are also signed by the operator key, so a valid signature is not sufficient — the sub must be the operator.

This is the only endpoint that updates an existing transparency log row: it writes ots_proof onto the newest entry. That proof is the temporal evidence of when the head existed, so overwriting it degrades the log's core claim; the endpoint also drives an outbound OpenTimestamps calendar submission. Both are why it is gated (issue #501).

Anchoring is a manual operator action. GET /api/v1/log/anchor-status is public and its advisory anchor_due flag is what tells a polling operator that a call to this endpoint is warranted.

The request takes no body.

Response:

{
  "anchored_entry_id": 42,
  "entry_hashes": [
    {
      "algorithm": "sha2-256",
      "digest_hex": "3f2a...",
      "is_primary": true
    }
  ],
  "ots_proof": "base64-encoded-proof..."
}

When the log is empty the response is {"detail": "No entries to anchor"}.

Error responses:

Status Meaning
401 Missing, malformed or unverifiable operator JWT
403 Token verifies but its sub is not the operator fingerprint
503 The operator key provider is unavailable — the route fails closed

Attestation Requests

POST /api/v1/log/attestation-requests

Ask another key for an attestation. Self-requests (requester == target) are valid.

Authentication required (issue #503). Prove control of requester_fingerprint either way:

  • Session — send Authorization: Bearer <token>. The session's key is authoritative: a requester_fingerprint that disagrees is a 403, and an absent one is filled in from the session rather than trusted.
  • Detached proof — send an auth object ({challenge_id, challenge_signature}) from POST /api/v1/auth/challenge, signed by requester_fingerprint.

Recovery-only sessions are refused.

The route was previously unauthenticated. Every accepted request becomes a permanent, unretractable log entry attributed to a caller-supplied fingerprint, and raises an inbox notification at the target — so it was both an attribution-forgery primitive and an unmetered way to flood a victim's inbox.

Limits. requester_fingerprint and target_fingerprint 256 chars, attestation_type 512, nonce 128, intent_statement 4096; claims is capped at 64 KiB of serialized JSON — by size, not key count, since one large string value defeats any per-key rule.

Quota. At most RATE_LIMIT_ATTESTATION_REQUEST_PER_TARGET (default 50) requests per target per hour. Keyed on the target, not the requester: the abuse is flooding one victim, and a per-requester cap is defeated by registering more keys.

Error responses:

Status Meaning
401 No session and no auth proof
403 Proof or session is for a different key, or a recovery-only session
409 Duplicate request nonce
422 A field or the claims object exceeds its cap
429 The target's hourly request quota is exhausted

POST /api/v1/log/attestation-requests/{request_id}/respond

Accept or decline a request.

Authentication required (issue #504). Prove control of responder_fingerprint with a session token or an auth object, exactly as for creating a request. The proven fingerprint is what is recorded; an absent or empty responder_fingerprint is filled in from the session rather than stored as given.

The route previously copied a caller-supplied responder_fingerprint — and a caller-supplied attestation_id — into a permanent log entry, so any anonymous caller could record that an arbitrary key had accepted or declined an arbitrary request. The forgery also drives an inbox notification presenting the fabricated acceptance to the real requester as genuine, which is why the proof is checked before both writes.

reason is capped at 4096 characters.

Error responses:

Status Meaning
401 No session and no auth proof
403 Proof or session is for a different key, or a recovery-only session
404 No such request
410 The request's deadline has passed
422 reason exceeds its cap

Known limit. Proof binds the response to the key it names; it does not require that key to be the request's target. A key holder can therefore record a truthfully-attributed response to a request addressed to someone else. That is a narrower problem than H-3's forgery and is not in this issue's scope.

POST /api/v1/log/request-policies

Publish the request-filtering policy for a target key.

Authentication required (issue #505). Prove control of target_fingerprint with a session token or an auth object. A policy is self-published: there is no way to publish one for a key you do not hold.

The route previously took a client-chosen target_fingerprint with no proof at all. Beyond the obvious forgery, the supersession rule means a publish replaces the target's active policy — so an attacker could rewrite a victim's filtering rules, and since #483 those rules are evaluated against inbound requests. A planted policy therefore silently filtered the legitimate traffic the victim was meant to receive. The proof is checked before the supersession lookup, so an unproven caller cannot probe the existing policy either.

predicates is capped at 64 KiB serialized; default_action_on_failure at 64 characters. At most RATE_LIMIT_REQUEST_POLICY_PER_TARGET (default 20) publications per target per hour — self-publishing makes flooding self-harm, but each publish is still a permanent log entry and a rapid series churns what is enforced.

Error responses:

Status Meaning
401 No session and no auth proof
403 Proof or session is for a different key, or a recovery-only session
409 effective_from is not later than the active policy's
422 Invalid predicates, invalid default action, or a field over its cap
429 The target's hourly publication quota is exhausted

Endorsements

POST /api/v1/endorsements/offer

Offer an endorsement to another key holder.

Request body:

Field Type Required Description
endorser_fingerprint string Yes Fingerprint of the endorsing key
endorsed_fingerprint string Yes Fingerprint of the key being endorsed
category string Yes "x-diogenes:human_attestation" or "x-diogenes:institutional_endorsement"
valid_until string No Expiry date (ISO 8601)
institutional_metadata object No Metadata for institutional endorsements
external_identifiers array No External identity links
auth object Yes Auth proof (challenge_id + challenge_signature)

Response: Log entry for the endorsement offer event.

POST /api/v1/endorsements/accept

Accept a pending endorsement offer.

Request body:

Field Type Required Description
offer_entry_id integer Yes Log entry ID of the endorsement offer
endorsed_fingerprint string Yes Fingerprint of the endorsed key
auth object Yes Auth proof for the endorsed key

POST /api/v1/endorsements/withdraw

Withdraw an endorsement (endorser action).

Request body:

Field Type Required Description
endorser_fingerprint string Yes Endorser's fingerprint
endorsed_fingerprint string Yes Endorsed key's fingerprint
original_offer_entry_id integer Yes Original offer log entry ID
note string Yes Reason for withdrawal
auth object Yes Auth proof for the endorser

POST /api/v1/endorsements/revoke-acceptance

Revoke acceptance of an endorsement (endorsed party action).

Request body:

Field Type Required Description
endorsed_fingerprint string Yes Endorsed key's fingerprint
original_offer_entry_id integer Yes Original offer log entry ID
note string Yes Reason for revocation
auth object Yes Auth proof for the endorsed key

GET /api/v1/endorsements/{fingerprint}

Get endorsements received by a key.

GET /api/v1/endorsements/{fingerprint}/issued

Get endorsements issued by a key.

GET /api/v1/endorsements/{fingerprint}/status

Get endorsement status summary for a key.

POST /api/v1/endorsements/revocation-alert

Publish an endorser revocation alert.

Request body:

Field Type Required Description
endorser_fingerprint string Yes Alerting endorser's fingerprint
compromised_fingerprint string Yes Compromised key's fingerprint
reason string Yes Alert reason (max 1000 chars)
evidence string No Supporting evidence (max 5000 chars)
auth object Yes Auth proof for the endorser

GET /api/v1/endorsements/revocation-alerts/{fingerprint}

Get revocation alerts for a key.


Graph Traversal

GET /api/v1/graph/{fingerprint}

Depth-limited BFS traversal of the endorsement graph.

Query parameters:

Parameter Type Default Description
max_depth integer 3 Maximum traversal depth
type string -- Filter by endorsement type
status string -- Filter by endorsement status
institution string -- Filter by institution
since string -- Only endorsements after this date
until string -- Only endorsements before this date

Results are cached (default TTL: 60 seconds) and subject to a configurable timeout (default: 10 seconds).


Trust Configurations

POST /api/v1/trust-configs

Create a trust configuration for a key.

GET /api/v1/trust-configs

List trust configurations.

GET /api/v1/trust-configs/{fingerprint}

Get trust configuration for a specific key.

POST /api/v1/trust-configs/revoke

Revoke a trust configuration.


Encrypted Claims

POST /api/v1/claims

Post an encrypted claim to the transparency log. Requires a JWT and a hardware-backed signing key.

The payload must declare "commitment_alg": "hmac-sha256-ecdh-v1". A payload that declares "sha256" — or omits the field, which means the same thing — is rejected with 400 (#517): an unkeyed commitment over a short structured plaintext, published beside its ciphertext, is brute-forceable offline, so minting new ones must fail loudly rather than silently succeed from a stale SDK. Relays that forward payloads verbatim need to upgrade their sealing side.

Response:

{
  "entry_id": 1,
  "commitment": "9f3c...",
  "leaf_index": 42,
  "log_entry_id": 1
}

GET /api/v1/claims

List encrypted claims by lookup_key. Possession of the token is the access control.

GET /api/v1/claims/{entry_id}

Get a specific encrypted claim. Returns the opaque payload only.

POST /api/v1/claims/{entry_id}/reveal

Verify a revealed plaintext against the entry's stored commitment.

Offline verification is the preferred path and does not need this endpoint — a holder of the plaintext and the entry's commitment key checks it in-process and discloses nothing. Calling this route discloses the plaintext and that one entry's commitment key to the log operator.

Auth: requires a JWT (#517). Rate limits: per IP per hour (DIOGENES_RATE_LIMIT_CLAIM_REVEAL, default 30) and per (entry, authenticated caller) per hour (30). The per-caller keying is deliberate: it slows a guesser without giving anyone a lever to deny an honest auditor access to a specific entry.

Request body:

{
  "plaintext": { "key": "value" },
  "commitment_key": "0123abcd… (64 hex chars)"
}

Response:

{
  "valid": true
}

Status codes:

Code Meaning
200 Verdict returned in valid
400 Blinded entry and no commitment_key supplied
401 No JWT
404 No such encrypted-claim entry
409 The entry carries a pre-#517 unsalted commitment. Server-side reveal is withdrawn for those: for such an entry this route's only unique capability is acting as a guess-checker, since anyone holding the plaintext verifies it offline in one line. Those entries stay verifiable offline forever.
422 commitment_key is not 64 hexadecimal characters
429 Per-IP or per-(entry, caller) budget spent

Composite Documents

POST /api/v1/composite/assemble

Assemble a composite document from multiple parts.

POST /api/v1/composite/compile

Compile a composite document into a single manifest.


Audit Export

GET /api/v1/audit/export

Export audit trail in JSON or CSV format with combinable filters.

Query parameters:

Parameter Type Description
format string "json" or "csv"
scope string Filter scope
fingerprint string Filter by fingerprint
document_hash string Filter by document hash
start_date string ISO 8601 start date
end_date string ISO 8601 end date
page integer Page number
page_size integer Page size (max 1000, total max 10000)

Webhooks

POST /api/v1/webhooks

Register a webhook for event notifications.

GET /api/v1/webhooks

List registered webhooks.

DELETE /api/v1/webhooks/{webhook_id}

Delete a webhook.


Identifier Types

GET /api/v1/identifier-types

List supported external identifier types.


Rate Limits

The API enforces the following rate limits:

Resource Default Limit
General API queries 100 requests/minute
Key registrations 10/hour
Endorsement offers 20/day
Attestation events (incl. POST /api/v1/documents/sign) 50/hour
Log head anchoring 12/hour
PIN proof checks 5 per 15 minutes, per fingerprint

All limits are per client IP except PIN proof checks, which are keyed by fingerprint so a per-user lockout holds regardless of source address.

Rate limit headers are included in all responses.