API reference
03 · API reference
Two endpoints. There is no update route and no delete route, deliberately and permanently — an append-only log whose API can rewrite entries isn't append-only. If a record needs correcting, append a correction; that's what an audit trail is for.
Authentication
Every request needs an API key, sent one of two ways:
Authorization: Bearer <key> | preferred |
X-API-Key: <key> | also accepted |
A missing key, an unparseable key, and a well-formed key that doesn't belong to any
customer all return the identical 401 response — same status,
same body. This is deliberate: distinguishing "bad key" from "key belongs to nobody" would
leak whether a given key string is real.
POST /v1/events
Records one event and returns immediately with a pending receipt — before the batch it lands in has been anchored. The write path never blocks on a blockchain transaction; anchoring happens on the platform's own schedule.
Request body
| field | type | required | notes |
|---|---|---|---|
event | JSON value | yes | Any JSON object/array/primitive that canonicalises without error — see canonical JSON. Object fields, in particular, must not contain non-integer numbers. |
idempotencyKey | string | no | See idempotency below. |
curl -X POST https://api.backrun.example/v1/events \ -H "Authorization: Bearer $BACKRUN_KEY" \ -d '{"event":{"actor":"bob@acme.example","action":"record.delete","recordId":4471}, "idempotencyKey":"acme-88213"}'
Response — 201 Created (new event)
{
"status": "pending",
"eventId": "b1f0e2a4-…",
"leaf": "0x68081b59…",
"canonical": "{\"action\":\"record.delete\",…}",
"receivedAt": "2026-08-11T21:04:12.000Z"
}
canonical is the exact string that was hashed — echoing it back lets you
confirm your event canonicalised the way you expected before the batch is even anchored.
It becomes null later if the event is erased (see
erasure & retention); the leaf and proof
survive erasure, the payload does not.
Response — 200 OK (deduplicated retry)
Identical body to the 201 case, same eventId, but status 200
because nothing new was created — see idempotency.
Error responses
| status | code | when |
|---|---|---|
| 400 | invalid_json | Body is not parseable JSON. |
| 400 | invalid_request | Body isn't a JSON object; event is missing; or idempotencyKey is present but not a string. |
| 401 | unauthorized | Missing, malformed, or unrecognised API key. |
| 413 | payload_too_large | Body exceeds the size limit (Content-Length checked first, then the actual body re-checked after reading — a forged header can't bypass the limit). Default limit: 64 KiB. |
| 422 | uncanonicalisable_event | The event contains something canonicalisation rejects — most commonly a non-integer number. Nothing is stored; this fails at the door rather than being accepted and found unverifiable later. |
Error body shape, consistent across every endpoint:
{ "error": { "code": "uncanonicalisable_event", "message": "non-integer number 0.1 cannot be canonicalised deterministically; send a string or a scaled integer instead" } }
Idempotency
Send the same idempotencyKey twice (from the same customer) and the second
call returns the original event — same eventId, same leaf —
with status 200 instead of 201, and nothing new is written.
Retries are normal for any HTTP client, and a duplicated audit entry is itself a corrupted
record: "did this happen once or twice?" is exactly the question an audit log exists to
answer, so the API answers it rather than silently creating two records.
Scoping is per customer — two different customers may legitimately reuse the same key string without colliding. There is no global idempotency namespace.
GET /v1/events/:id/receipt
Returns the receipt for an event you submitted — pending if its batch hasn't
anchored yet, anchored once it has. No parameters beyond the event id in the
path.
curl https://api.backrun.example/v1/events/b1f0e2a4-…/receipt \
-H "Authorization: Bearer $BACKRUN_KEY"
Response — 200, status pending
{
"status": "pending",
"eventId": "b1f0e2a4-…",
"leaf": "0x68081b59…",
"canonical": "{\"action\":…}",
"receivedAt": "2026-08-11T21:04:12.000Z"
}
Also returned for an event whose batch was created but whose anchor transaction hasn't
confirmed yet — reporting pending in that case is more honest than issuing a
proof against a root that doesn't exist on-chain yet.
Response — 200, status anchored
{
"status": "anchored",
"version": 2,
"eventId": "b1f0e2a4-…",
"canonical": "{\"action\":…}",
"salt": "0x0202…0202",
"eventHash": "0x68081b59…",
"leaf": "0x68081b59…",
"proof": [ { "sibling": "0x…", "siblingIsLeft": true }, … ],
"root": "0x9c84221263e9265b0e2e62bd356393fa6f30c01339e26f21ab645da2da652195",
"batchId": "0xe1f0680050984951b9bd7f250e75441800000000000000000000000000000000",
"chainId": 8453,
"contract": "0x256080339DEA7E8F3089C49CCEB0F98547103f91",
"publisher": "0x16EF29F63A4CB0624EF5c1079E08E1530A0b7142",
"anchorTxHash": "0xa6f5999326a7e32173afb7b1d660830103254b11f63b9656bf41d1b6cf262f9a",
"anchoredAt": "2026-08-11T20:07:31.000Z",
"erasedAt": null
}
root, batchId, contract, publisher and anchorTxHash above are the real values from the first anchor on Base mainnet — see reading the anchor on-chain for how to look them up yourself.
Every field here, and why each one exists, is documented field-by-field on the receipt format page. This response is a receipt, in the exact shape a verifier needs — see the verification algorithm.
Error responses
| status | code | when |
|---|---|---|
| 401 | unauthorized | Same as above. |
| 404 | not_found | The event doesn't exist, or exists but belongs to a different customer. Both cases return the identical response. |
Why 404, never 403, for another customer's event
A 403 Forbidden would confirm the id exists — leaking, to anyone who can guess
or enumerate ids, which ones correspond to real (someone else's) audit events. Reporting
"not found" either way means an id gives an attacker no information at all.
Everything else
| request | status | code |
|---|---|---|
PUT / PATCH / DELETE on /v1/events/:id | 404 or 405 | not_found / method_not_allowed |
| Wrong HTTP method on a known route | 405 | method_not_allowed |
Any path outside /v1/, or an unrecognised route | 404 | not_found |
These aren't unimplemented — they're structurally absent. There is no code path in the engine, the store, or the on-chain contract that mutates or removes a past event. See reading the anchor on-chain for the same guarantee at the contract level.