API reference
The Dokaz REST API is versioned under /v1. The machine-readable OpenAPI 3.1 document is at /openapi.json.
Authentication
Create an API key on the API keys page, then send it as a bearer token. The examples below are identical requests — pick the one for your shell.
curl https://dokaz.net/v1/drills -H "Authorization: Bearer so_your_key_here"curl.exe https://dokaz.net/v1/drills -H "Authorization: Bearer so_your_key_here"In PowerShell, curl is an alias for Invoke-WebRequest, which does not accept -H. Call curl.exe to use the real curl, or use the native cmdlet:
Invoke-RestMethod https://dokaz.net/v1/drills -Headers @{ Authorization = "Bearer so_your_key_here" }Conventions
- Every response is a
{ data, meta, errors }envelope. - List endpoints paginate with an opaque
cursor; followmeta.next_cursor. - State-changing requests (
POST,PATCH,DELETE) require anIdempotency-Keyheader; a repeated key replays the original response.test-sourcechanges nothing and needs none, and the streamingPUT …/upload(whose body is the dump) takes none: each PUT is a new upload. - Requests are rate-limited per account; over the limit returns
429withRetry-After.
Connecting a backup
A database's source is either an S3-compatible bucket ("kind": "s3" — AWS S3, Cloudflare R2, Backblaze B2 or MinIO, with a read-only key and either an exact key or the newest backup under a prefix) or "kind": "push", where your backup job uploads to Dokaz. Endpoints must be https://. The secret access key is write-only: it is encrypted per account and never returned — responses show only credentials.access_key_hint.
curl https://dokaz.net/v1/databases \
-H "Authorization: Bearer so_your_key_here" \
-H "Idempotency-Key: 6f1c0d2e-prod-postgres" \
-d '{"name": "prod-postgres",
"source": {"kind": "s3", "region": "eu-west-1",
"bucket": "acme-prod-backups", "prefix": "postgres/prod/",
"access_key_id": "AKIA...", "secret_access_key": "..."}}'POST /v1/databases/{id}/test-sourceprobes the saved source (or a candidatesourcebody) and answers{ ok, code, message, object }; 10 tests per minute per account.PATCH /v1/databases/{id}/sourcereplaces the source; omit both credential fields to keep the saved key, or send a new pair to rotate it.- Drills of
s3andpushsources run in an isolated, single-use restore sandbox that receives only a 15-minute presigned link to the one backup object — never your credentials. On a server where the sandbox is not configured,POST /v1/drillsanswers409 sandbox_unavailable(a push source on a server without an ingest bucket answerspush_unavailable);source.drills_availablesays which applies.
The key needs s3:GetObject on the backups and s3:ListBucket on the bucket — nothing else.
Pushing backups
A database with "source": {"kind": "push"} receives its dumps from your backup job. Use an API key with the uploads:write scope (add drills:write to drill each upload). The simplest way is one curl after the dump is written; it streams the file (up to 5 GiB and your plan's dump size) and Dokaz computes its SHA-256 as it arrives:
pg_dump -Fc -f dump.pgdump "$DATABASE_URL"
curl -fsS -T dump.pgdump \
-H "Authorization: Bearer so_your_key_here" \
"https://dokaz.net/v1/databases/<database-id>/upload?drill=1"Content-Lengthis required (411without it, so upload a file, not a pipe); over the limit is413. Optional:X-Content-SHA256(or?sha256=) is checked against the bytes received (422 sha256_mismatchdeletes the upload),?declared_backup_at=(RFC 3339) records when the backup was taken for RPO, and?drill=1starts a drill.- For larger dumps, or to upload straight to storage:
POST /v1/databases/{id}/uploadswith optionalsize_bytes,multipart,declared_backup_atandsha256. It returns eitherput.url(a presigned PUT, valid 15 minutes, up to 5 GiB) or, above 5 GiB or with"multipart": trueandsize_bytes,multipart.parts(presigned part URLs valid 12 hours, eachpart_size_byteslong except the last). - Then
POST /v1/databases/{id}/uploads/{upload_id}/complete, optionally with"drill": true. A multipart upload also sends its parts, with the ETag each part PUT returned:{"parts": [{"part_number": 1, "etag": "…"}, …]}. A dump over your plan's limit is deleted and answers413. A declaredsha256is recorded and checked against the bytes the restore sandbox downloads. - Each upload is deleted once its drill is done, unless the source sets
retain_uploads_days(0–7); uploads never outlive 7 days, and unfinished uploads are removed after 24 hours. Uploads are limited to 10 per minute and 20 in progress per account. - The upload response's
drillis the drill it started, ordrill_errorsays why none was (for examplesandbox_unavailableon a server without the restore sandbox); the upload is complete either way.
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /v1/databases | List database targets |
| POST | /v1/databases | Register a database target |
| GET | /v1/databases/{id} | Get a database target |
| PATCH | /v1/databases/{id} | Set a database's RTO / RPO targets |
| PATCH | /v1/databases/{id}/source | Change (or rotate the key of) a database's backup source |
| POST | /v1/databases/{id}/test-source | Test a backup source: finds the backup a drill would use |
| GET | /v1/databases/{id}/suggestions | List checks proposed from the restored schema |
| POST | /v1/databases/{id}/suggestions/{sid}/accept | Accept a suggested check (creates the assertion) |
| POST | /v1/databases/{id}/suggestions/{sid}/dismiss | Dismiss a suggested check for good |
| PUT | /v1/databases/{id}/upload | Stream a dump to a push database (curl -T, up to 5 GiB) |
| POST | /v1/databases/{id}/uploads | Start a presigned upload (single PUT or multipart) |
| POST | /v1/databases/{id}/uploads/{upload_id}/complete | Finish a presigned upload, optionally starting a drill |
| GET | /v1/drills | List drills |
| POST | /v1/drills | Start a drill |
| GET | /v1/drills/{id} | Get a drill with steps, assertions + recovery metrics |
| GET | /v1/drills/{id}/evidence | Download the signed evidence PDF |
| GET | /v1/drills/{id}/signature | Get the detached Ed25519 signature for the evidence PDF |
| GET | /v1/drills/{id}/logs | Get captured restore output (snippet + SHA-256 of the full log) |
| GET | /v1/drills/{id}/schema-drift | Get schema changes since the previous successful drill |
| GET | /v1/alerts | List alerts |
| GET | /v1/heartbeats | List backup check-ins |
| GET | /v1/heartbeats/{id} | Get a backup check-in |
| GET | /v1/integrations/compliance | List compliance-platform connections (Vanta, Drata — beta) |
| POST | /v1/integrations/compliance | Connect Vanta or Drata — beta (credentials are write-only) |
| GET | /v1/integrations/compliance/{id} | Get a compliance-platform connection |
| PATCH | /v1/integrations/compliance/{id} | Change a connection's target, databases or toggles; rotate its credentials |
| POST | /v1/integrations/compliance/{id}/test | Test a connection's credentials and target |
| DELETE | /v1/integrations/compliance/{id} | Disconnect (destroys the stored credentials) |
Suggested checks and schema drift
After every restore Dokaz records the restored database's schema — tables, columns and types, primary and foreign keys, row counts and the newest value of created/updated-style columns. Names, types, counts and timestamps only, never row data. From it, Dokaz proposes checks by fixed rules, each with its reason: key tables get table_exists, row counts get a row_count floor at about 90% of the rows seen, key columns get column_exists, id- and email-like columns that had no NULLs get no_nulls, live tables get freshness, and foreign keys get orphan checks. Nothing runs until you accept it; dismissed suggestions stay dismissed.
freshnesstakes{"table", "column", "max_age_seconds"}and passes when the column's newest value is at most that far behind the drill's backup time, or its start when the backup time is unknown; the result says which.GET /v1/drills/{id}/schema-driftlists tables and columns added or removed, type changes, row-count drops over the database's threshold (20% unless changed on the database page) and newest timestamps that went backwards, compared with the previous successful drill. Removals and big drops are also sent by email, to Slack and as adrill.schema_driftwebhook. Drift is informational: it never changes a verdict.
Compliance platforms (Vanta, Drata)Beta — tell us if anything looks off
Both providers are in beta: the integrations are built and tested against each vendor's published API documentation, not yet proven against live Vanta and Drata accounts. If a push lands differently from what is described here, email support@dokaz.net.
A connection pushes every finished drill's signed evidence into the platform: the PDF, its signature JSON (Vanta receives it as .json.txt, the extension it accepts) and a plain-text summary with the database, verdict, measured RTO / RPO and the framework mapping (SOC 2 A1.2 and A1.3, ISO/IEC 27001:2022 Annex A 8.13, HIPAA 45 CFR 164.308(a)(7)(ii)(D), and optionally DORA Article 12). It supports evidence for those requirements; it is not a certification. Failed drills are pushed too unless push_failed_drills is false. These endpoints need the opt-in integrations:read / integrations:write scopes; the same settings are under Account → Compliance platforms.
curl https://dokaz.net/v1/integrations/compliance -H "Authorization: Bearer so_your_key_here" -H "Idempotency-Key: connect-vanta-1" -d '{"provider": "vanta", "client_id": "vci_...", "client_secret": "...",
"document_id": "backup-restoration-test", "push_failed_drills": true}'- Vanta: create an application in Vanta's Developer console with the
vanta-api.all:read,vanta-api.all:writeandvanta-api.documents:uploadscopes, and pass itsclient_id/client_secretwith thedocument_idevidence goes to. Files are uploaded to the document and the document is submitted. - Drata: pass an
api_keywith the Evidence Library: Create Evidence permission, theworkspace_id, optionalregion(us,eu,apac) andcontrol_ids. Each drill becomes one evidence item. - Secrets are never returned; responses carry
credential_hint. When the platform rejects them the connection becomesreconnect_neededand pushes stop until new credentials are sent withPATCH, which also retries the failed pushes. - Each push is idempotent per drill and connection: a retried push never uploads a file twice.
Recovery metrics (RTO / RPO)
Every drill measures how long recovery took and how old the backup was. Each drill response carries a recovery object:
rto_seconds: fetch + restore wall-clock in the sandbox (fetch_ms+restore_ms, rounded up). Null when no restore completed.rto_basis: for drills of s3 and push sources,agentwhen the times are the sandbox's own, cross-checked against when Dokaz received its progress events, orapp_observedwhen the two disagreed and Dokaz's observation was recorded instead. Null for the built-in sample.rpo_seconds: drill start minusbackup_at.backup_at_sourcesays where the backup time came from. A file modification time (file_mtime), S3 Last-Modified or upload-completion time is only the latest the backup could have been taken (backup_at_upper_bound: true), so the true RPO is at least the value shown. The built-in sample dataset has no backup time, so its RPO is null.targets: the database's RTO / RPO targets, copied onto the drill when it starts.within_targetsis null when none were set.
Set targets with PATCH /v1/databases/{id} and a body such as {"rto_target_seconds": 3600, "rpo_target_seconds": 86400} (null clears a target), or under Recovery targets when you add or edit a database in the app. A drill that misses a target fails, with rto_max / rpo_max results next to its data assertions. An RPO target fails when the backup time cannot be measured. The signed PDF has a Recovery metrics section, and the current signature version (ed25519-v3) also covers these values: the signature JSON carries the signed recovery object, and dokaz-verify rejects evidence whose recovery figures were edited.
Public transparency log
Every evidence signature is appended to a public, append-only Merkle log (RFC 6962 hashing), so a report cannot be quietly re-signed, backdated or withdrawn: once a signed checkpoint covers it, anyone can prove it was in the log. Each leaf is one line holding only algorithm, public_key_id, signature_sha256 (SHA-256 of the raw signature), pdf_sha256 and signed_at — no account, customer, database or drill identifier:
dokaz-evidence-log/v1 algorithm=ed25519-v3 public_key_id=3b6a…e1 signature_sha256=9f2c…07 pdf_sha256=c41d…5a signed_at=2026-10-12T09:30:00.123456Z
Every 10 minutes, when new leaves exist, the log signs a checkpoint in the C2SP tlog-checkpoint signed-note format — origin dokaz.net/log, tree size, base64 root hash and a timestamp line — with the evidence signing key. The signature JSON (GET /v1/drills/{id}/signature and the evidence bundle) carries a log object with the leaf index, the inclusion proof and that checkpoint; it is null until a checkpoint covers the signature. Check it offline with dokaz-verify --log --sig=signature.json --pubkey=evidence-signing-keys.pem.
What it does and does not prove: the checkpoint timestamp, like signed_at, comes from Dokaz's own clock — it is not an independent timestamp authority. What the log adds is that history cannot be quietly rewritten: anyone who keeps an earlier checkpoint can demand a consistency proof to the current one, and a report removed or re-signed after the fact would not match. Independent witnesses co-signing the checkpoints are not in place yet.
These endpoints need no API key (rate-limited per IP; responses carry cache headers):
| GET | /log/checkpoint | Latest signed checkpoint (text/plain signed note) |
| GET | /log/key | Signed-note verifier keys the checkpoints are signed with |
| GET | /log/proof/inclusion?index=N | Inclusion proof for a leaf (or ?pdf_sha256=…; optional &tree_size=M) |
| GET | /log/proof/consistency?old=M&new=N | Consistency proof between two tree sizes |
| GET | /log/leaves?start=N&limit=K | Leaves covered by the latest checkpoint, 100 per page |
Owners and admins can also opt in to a public trust badge under Account → Trust badge: an SVG (/badge/<code>.svg) and page (/trust/<code>) showing recent drill dates, verdicts and their log indices, behind a random, regenerable code. Database names stay hidden unless chosen per database.