The HTTP API
Every route the runtime generates, the status each outcome maps to, and the exact response bodies.
The surface is generated from your declarations. There is nothing to register and no routing table to keep in step: the route is the declared name.
Running a command
POST /commands/{Name}
The name is the declared command name, never a file name. Commands under
commands/internal/ get no route at all.
The body is the parameters as JSON. An empty body is an empty object; a body that is not an
object is a 400.
| Header | Effect |
|---|---|
x-correlation-id |
used when it parses as a uuid, otherwise a fresh one is minted |
idempotency-key |
a repeat returns the original response instead of acting again |
A blank idempotency-key is treated as no key at all.
Status mapping
| Status | Code | When |
|---|---|---|
200 |
committed, or already committed under this idempotency key | |
400 |
invalid_input |
arguments failed validation, or the author returned invalid(...) |
409 |
concurrency_conflict |
the boundary kept changing past the retry budget |
422 |
the refusal name in snake_case | the author returned reject Name |
404 |
not_found |
no such command, or an internal one |
503 |
unavailable |
the runtime cannot accept writes right now |
500 |
internal |
a handler error or a panic |
Success
{
"correlation_id": "eaa1d143-1a24-4a0e-9f14-2b0f2b6f5a11",
"causation_id": "425d5c99-9b5f-4c2e-8f3a-1d9f0c7a2b44",
"positions": { "first": 1, "last": 1 },
"events": [
{
"type": "booking.made",
"tags": ["booking_id:1111...", "guest_id:7"]
}
]
}
positions is null and events is [] when the command decided to do nothing, and that
is still a 200. A repeat under an idempotency-key is a different thing: the original
response is recovered from the log and returned whole, positions and events included.
Tags exclude subject-encrypted values and the runtime’s own _hekla_ tags, and they are
sorted, so a fresh commit and a log-recovered replay are byte-identical.
An error
{
"correlation_id": "656b4c61-fb47-4fa9-a125-310197b3125a",
"causation_id": "0d7d6aba-fcf2-46fc-bffd-73a70cc959bb",
"error": {
"code": "already_joined",
"message": "that email is already on the list"
}
}
Every command error carries the two ids, which is what lets a client report something you
can find in /admin/traces/{correlation_id}.
Reading a projector
Two routes per entity.
GET /read/{projector}/{entity}/{key}
GET /read/{projector}/{entity}
{
"item": { "email": "ada@example.com", "entry_id": "0190d1a1-..." },
"position": 1
}
A scan answers a list with a cursor:
{
"items": [{ "email": "ada@example.com", "entry_id": "0190d1a1-..." }],
"next_cursor": null,
"position": 1
}
| Parameter | |
|---|---|
limit |
clamped to 1..500, default 50 |
cursor |
opaque; pagination is never an offset |
after |
wait until the projector has reached this position |
timeout_ms |
how long to wait, default 5000, maximum 30000 |
| any other | a filter on that field |
Only one filter field is supported. Two is a 400 with unindexed_filter, and so is a
filter on a field that is neither the key nor the leftmost column of a declared index. A
column that is subject-encrypted cannot be indexed at all; filter by the plaintext subject id
instead.
An absent column is omitted from a row rather than sent as null, which is also what an erased subject’s column looks like.
Read your writes
A read model is eventually consistent by default. To see your own write, pass the
positions.last a command returned:
curl 'localhost:8080/read/Waitlist/Entry?email=ada@example.com&after=1'
The read blocks until the projector reaches that position rather than quietly serving a
stale row. timeout_ms=0 means check once and do not wait.
Read errors
| Status | Code | Means | Retry-After |
|---|---|---|---|
400 |
invalid_input |
a malformed parameter | |
400 |
invalid_after |
after is not a non-negative integer |
|
400 |
unindexed_filter |
more than one filter, or an undeclared one | |
404 |
not_found |
no such row, projector or entity | |
503 |
not_caught_up |
after was not reached in time |
yes |
503 |
rebuilding |
the projector is rebuilding | yes |
503 |
stale |
the definition changed and no rebuild is running | no |
503 |
rebuild_failed |
see last_error in /status |
|
503 |
quarantined |
its checkpoint moved backwards, so neither its rows nor its position can be vouched for; last_error in /status says which |
|
500 |
internal |
a read failed or a task panicked |
The Retry-After rule is a deliberate signal rather than an oversight: rebuilding
resolves on its own, so it carries one. stale needs an operator, so it does not.
Read errors carry no correlation ids, because a read caused nothing.
Operator routes
POST /projectors/{name}/replay
POST /effects/{name}/skip/{position}
A replay answers 202 {"status": "replay_scheduled"}, or 503 not_running if that
projector has stopped.
A skip answers 202 for any position, including one that does not exist. The driver
honours the request only once that position has actually failed, and forgets it once the
watermark passes. Nothing is ever skipped automatically.
There is no HTTP equivalent of hekla rewind, on purpose. See
Effects in production.
Status and health
GET /status
GET /health
/health is {"status":"ok"} and nothing else. /status is the whole picture: log head,
uptime, every projector’s position, lag, readiness and last error, and every effect’s state,
lag, wedged lane count, pinning key and pinning position.
Metrics
GET /metrics
Prometheus text exposition, text/plain; version=0.0.4. Gauges are read at scrape time, so
there is no collection interval and nothing to configure. The full series list is in
Deploying.
The generated reference
GET /openapi.json
GET /docs
/openapi.json is a generated OpenAPI 3.1 document covering every public command, both
routes for every entity, and the operator and introspection routes. hekla openapi .
prints the same document without starting a server.
The console
GET /admin
/admin is the same URL as the JSON API rather than a second one. A request naming
text/html in its Accept header gets the console; everything else gets the JSON byte for
byte unchanged.
The negotiation is careful about what counts:
- a range that matches HTML and not JSON selects the console, so
text/html,application/xhtml+xmlandtext/*all do */*, which is what curl and a barefetch()send, matches both equally, and an equal weight goes to JSON;q=0ontext/htmlopts out- a more specific range wins, so
text/htmlbeatstext/*beats*/* - responses carry
Vary: Accept
The console is compiled into the binary, so it works with no network at all.
Introspection
Every one of these is read-only, and answers JSON or the console by the same negotiation:
| Route | |
|---|---|
GET /admin/events |
filter by type (repeatable, OR) and tag (repeatable, AND) |
GET /admin/events/{position} |
one event, with decrypt defaulting to true |
GET /admin/traces/{correlation_id} |
the causal chain, with complete saying whether it is whole |
GET /admin/effects and /admin/effects/{name} |
state, lanes, and what is pinning the watermark |
GET /admin/effects/{name}/invocations |
paged; the first missing call is where it is stuck |
GET /admin/effects/{name}/invocations/{position} |
the journaled calls for one invocation |
GET /admin/projectors and /admin/projectors/{name} |
position, readiness, and counts on request |
GET /admin/commands and /admin/commands/{name} |
the declared surface |
GET /admin/subjects and /admin/subjects/{field}/{value} |
live or absent |
GET /admin/schema |
the deployed declarations |
GET /admin/system |
version, uptime, key store, and the effective configuration |
GET /admin/assets/{file} |
the console’s own assets, the one route not negotiated |
A request that decrypts writes an audit line.
/admin/subjects/{field}/{value} answers absent for a subject that was erased and for one
that never existed, and cannot distinguish them. That is a property of deleting the key
rather than a gap in the endpoint.