Skip to content
hekla

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+xml and text/* all do
  • */*, which is what curl and a bare fetch() send, matches both equally, and an equal weight goes to JSON
  • ;q=0 on text/html opts out
  • a more specific range wins, so text/html beats text/* 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.