REST API
The HTTP API carries the gRPC API’s model over JSON. It splits by purpose: writes, renders and definition listings live under /api/v1, and event and state reads live at revision-scoped paths under /db.
Base URL
Section titled “Base URL”- Sandbox:
https://sandbox.evidentsource.com - Your deployment: port
3000on each server, or whatever your load balancer exposes.
Writes, renders and definition metadata
Section titled “Writes, renders and definition metadata”| Route | Method | What |
|---|---|---|
/api/v1/db | POST | Create a database. Body: {"database_name": "…"} |
/api/v1/db/{database} | POST | Transact events with append conditions |
/api/v1/db/{database}/async | POST | Transact without waiting for the outcome |
/api/v1/db/{database} | DELETE | Delete a database |
/api/v1/db/{database}/{revision}/state-changes/{name}/v{version} | POST | Run a command: the request body is the command |
/api/v1/db/{database}/{revision}/state-views/{name}/v{version} | GET | Render a State View |
/api/v1/db/{database}/state-changes | GET | List published State Changes as a JSON array of objects with name and version |
/api/v1/db/{database}/state-changes | POST | Publish a State Change: multipart, with parts metadata and wasm |
/api/v1/db/{database}/state-views | POST | Publish a State View: multipart, with parts metadata and wasm |
/api/v1/db/{database}/state-views/{name}/expire-snapshots | POST | Expire a State View’s stored snapshots, of every version, so its next read folds from the first event. No body; answers {"epoch": n}. A retry after an unknown outcome may expire twice, which costs only one more refold. Needs the deployer role, which is enforced only when authentication is on |
/api/v1/db/{database}/presenters | POST | Publish a presenter: multipart, with parts metadata and wasm. The metadata names it and lists the content types it offers, most preferred first, each with an optional schema |
/api/v1/db/{database}/presenters | GET | List the presenters published in a database |
/api/v1/db/{database}/presenters/{name}/v{version} | GET | One presenter version’s definition |
/api/v1/db/{database}/transaction/{transaction_id} | GET | Look up a transaction by its id |
The State Change listing above does not take a revision. The corresponding path outside /api/v1, /db/{database}/state-changes, returns 400 for a missing revision; use /db/{database}/{revision}/state-changes for the negotiated listing. Presenters also have an unrevisioned listing under /api/v1. State Views do not: /api/v1/db/{database}/state-views supports POST publishing but has no GET listing.
In the command and render routes, {revision} can be a number, latest, or cached.
Revision-scoped reads live under /db/{database}/{revision}: the database itself, and under it /events, /streams, /subjects, /event-types, /state-views and /state-changes.
latestandcachedredirect to a concrete revision for the database itself and its listings: streams, subjects, event types, State Views and State Changes.- An event query, a single stream and a single event take a numeric revision only, so read the database first, then use the revision it reports.
- An event query travels as one
qparameter: the JSON query, in URL-safe base64 without padding. - An effective-time range names its cut:
endAt, or"endAtNow": true, which redirects to the query naming the time it resolved to. A range with neither, or both, is400. The response reports the cut it applied inx-effective-time-end-at. - There are no
/v1/*routes and noasOfTransactionTimeorasOfEffectiveTimeparameters. See Bi-temporal reads.
Database identity and deletion retries
Section titled “Database identity and deletion retries”All requests work with the database name alone. Database metadata also returns
database_id, a UUIDv7 identifying that particular instance, and created_at,
derived from its embedded millisecond timestamp. Deleting and recreating a name
produces a new ID. IDs are compared for equality, never by chronological order.
With Accept: application/json, create returns 201 and database metadata:
{ "database": "orders", "database_id": "0191d3c2-6a7e-7c3b-8f00-000000000001", "created_at": "2024-09-08T22:30:19.518+00:00", "basis": 0, "revision": 0, "revision_timestamp": "2024-09-08T22:30:19.518+00:00"}A JSON read at /db/orders/0 carries the same fields. You can ignore the ID for
normal reads and writes. To make a deletion safe to retry, capture that ID and
send it as the optional query parameter:
curl -X DELETE -H 'Accept: application/json' \ 'https://sandbox.evidentsource.com/api/v1/db/orders?database_id=0191d3c2-6a7e-7c3b-8f00-000000000001'Deletion returns 204. If the name no longer points to the supplied ID, the
request succeeds without deleting anything, including when another instance now
uses the name. Retry with the same captured ID. A malformed or empty supplied ID
returns 400; it never falls back to deleting by name.
Omitting database_id deletes the instance currently bound to the name and
returns 404 if none exists. Repeating a name-only delete after recreation can
delete the replacement. Kafka topic cleanup remains an operator task.
Rendering a State View
Section titled “Rendering a State View”curl "https://sandbox.evidentsource.com/api/v1/db/{database}/latest/state-views/{name}/v1?subject.todo_id={id}"- Parameters bind as
{attribute}.{parameter}, where the attribute isstream,subjectorevent_type. effective_time_end_atcuts an effective-time-ordered view, and the view needs one: an RFC 3339 time, ornow, which redirects to the URL of the time it resolved to. The response reports the cut it applied inx-effective-time-end-at. A revision-ordered view ignores a well-formed cut, andnowthere answers without a redirect, reporting no cut; a malformed time is still400.
Caching
Section titled “Caching”A successful read at a numeric revision carries Cache-Control: public, max-age=60 and a weak ETag. It is private instead, and varies with Authorization and Cookie, when the request carried a credential (an Authorization header, a cookie, or an identity header a load balancer or access gateway adds), when the server identified the caller, or when the server requires authentication, so a shared cache never serves one caller’s read to another. A client’s own cache can still serve a read for up to that minute after its caller’s grant is revoked. After a minute, send the tag back in If-None-Match: an unchanged read is answered 304 with no body, before any work. For a State View with no presenter, If-None-Match: * is answered 304 whenever the view has a state to serve; a presented view is answered by its presenter whatever * says.
Private State View reads vary on all header inputs declared by the configured identity extractor chain, including absent credentials that could select a higher-priority identity. Built-in bearer and ALB extractors declare their inputs. A custom extractor with undeclared inputs makes private reads no-store; IdentityExtractor::cache_vary_headers declares those inputs.
The tag covers everything the response depends on: the database instance, the State View’s name and version, its fold epoch, the revision, the cut, the bindings, and, for a presented view, the presenter’s version and settings, the negotiated content type and the parameters. The events at a revision never change, but the body served for the same URL can: after the database is deleted and recreated under its name, after an operator expires the view’s snapshots to repair a wrong fold, or once the version stops being served. Each changes the tag or the status, and the short max-age bounds how long a cache can keep the old body.
latest, cached and effective_time_end_at=now redirect with Cache-Control: no-store, since the next commit or the clock moves where they point. Errors carry no caching headers.
When authentication is on, Cache-Control: no-cache on a read from a caller with the deployer role, as well as the right to read, folds the view again from its first event. The refold reads none of the stored renders or snapshots, stores nothing, and always answers 200 with what it folded, whatever If-None-Match says. That answer can differ from what a cache holds for the same URL, so it carries Cache-Control: no-store and no ETag. It waits for capacity like any other render, and is refused with 503 when the server is busy. Anyone else’s no-cache, and every no-cache when authentication is off, is an ordinary read. gRPC and MCP have no HTTP caching.
Append conditions
Section titled “Append conditions”A transaction’s conditions use the proto shape in JSON. To require that nothing matching a selector was appended after the revision you read:
{"max": {"selector": {"equals": {"subject": {"hasValue": true, "value": "todo-123"}}}, "revision": 42}}streamandeventTypeare plain strings, butsubjectis{"hasValue": …, "value": …}, because an event may have no subject.- Always send
"hasValue": truewith a subject value. Without it, the server reads the selector as “events with no subject” and ignoresvalue, so the condition holds and protects nothing. - A violated condition answers
409. See Dynamic Consistency Boundaries.
Idempotent retries
Section titled “Idempotent retries”Give each write an id, and resend the same id on a retry: the server returns the result already recorded for it, instead of committing twice.
- A transaction carries its id in the body, as
"transaction_id". - A command carries it in the
x-batch-idheader.
A transaction sent without an id gets a new one, so retrying it commits again.
Content negotiation
Section titled “Content negotiation”On read routes the Accept header chooses the rendering: text/html gives the browser UI, text/plain gives TOON (a compact form for agents, with links and next actions), and application/json or anything else gives JSON. A State View render returns its state in the content type its definition declares, unless the definition names a presenter. Then the presenter answers: Accept is matched against the content types it offers (most specific range first, ties to the presenter’s first offer), anything it offers none of is 406, and the response carries Vary: accept. The presenter receives every query parameter except effective_time_end_at; one without a . is for the presenter alone, and is refused for a view that names none. Its next page, if any, is a Link: <…?continuation=…>; rel="next". The presenter’s body streams. Before sending the response headers, the server collects 64 KiB, then waits for a 20 ms pause, up to 100 ms in total or until the accumulated head reaches a 256 KiB settling threshold. Whole chunks are accumulated, so the head can exceed that threshold by part of one chunk. This initial response shares the request timeout. A presenter that fails while the headers are held is answered with its error’s status. If its body completes while they are held, the answer is sent whole, with a Content-Length unless compression removes the length.
Otherwise the response is sent chunked as the presenter writes, up to 256 MiB, and the whole body must reach the client within 60 s of the presenter’s start. If the presenter fails after the response headers are sent, the body passes 256 MiB, or the 60 s run out, the server ends the response without the last chunk when it next writes to the connection. Treat a body cut short that way as a failed read. An HTTP/1.0 request gets the body buffered whole, up to 16 MiB, and its presenter has 25 s, since no reader can slow it.
When the definition holds a schema, a successful unpresented blob render links to it with Link: </api/v1/db/{database}/state-views/{name}/v{version}/schema>; rel="describedby". That route returns the schema’s bytes. Presenter responses do not automatically include this schema link.
Machine-readable reference
Section titled “Machine-readable reference”Swagger UI is at /api/v1/swagger-ui, Redoc at /api/v1/docs, and the document at /api/v1/openapi.json. That document currently describes schemas only and lists no path operations, so generate clients from the proto files instead.
Authentication
Section titled “Authentication”Authorization: Bearer <token>. See Security.