On this page

Understand it · docs/api.md

HTTP API

The server's HTTP interface: eight endpoints, used by the application's own pages and usable by scripts. This page lists each endpoint with its authentication, request, response and errors, the command and audit entry formats, and a curl walk-through that was run against a real server.

The API exists in server mode only (COUNTERSIGN_STORAGE set). All bodies are JSON in UTF-8. All responses carry Cache-Control: no-store.

Contents

Overview

Method and path Purpose Sign-in needed CSRF header needed
GET /api/health Store, head hash and integrity state, for monitoring No No
GET /api/bootstrap Which mode this is and who is signed in No No
GET /api/entries?after=N The audit trail after entry N Yes No
POST /api/act Run one command as the signed-in person Yes Yes
GET /auth/login Start sign-in at the identity provider No No
GET /auth/callback Finish it (the redirect URI) No No
POST /auth/logout End the session No Yes
POST /auth/demo Pick a demo person (demo sign-in only) No Yes

There is no endpoint for single records, searches or the current state. A client fetches the audit trail and replays it with the engine in lib/ to obtain the records, exactly as the application's pages do; or it uses the command-line tool's export --what records. import and configure are not available over HTTP.

Authentication

COUNTERSIGN_AUTH What a request must carry
entra, oidc, demo The session cookie countersign_session, obtained by signing in (/auth/login in a browser, /auth/demo with demo sign-in).
proxy The headers the reverse proxy adds: X-Countersign-Proxy-Secret and the user, name and roles headers. See Authentication.

There are no API keys or bearer tokens. A request without valid identity to an endpoint that needs it gets

HTTP/1.1 401 Unauthorized
{"error":"Sign in first.","code":"signin_required"}

CSRF protection

Every request that changes something (POST /api/act, POST /auth/logout, POST /auth/demo) must satisfy two rules:

  1. It carries the header X-Countersign-Request: 1. A form on another site cannot set a custom header, and a script on another site can only send one after a cross-origin preflight, which the server never allows.
  2. If it carries an Origin header, that header equals the application's own address: COUNTERSIGN_PUBLIC_URL, or else the address derived from X-Forwarded-Proto, X-Forwarded-Host and Host. Requests without an Origin header (curl, server-side scripts) pass this rule.

Otherwise:

HTTP/1.1 403 Forbidden
{"error":"The request is missing the X-Countersign-Request header."}
HTTP/1.1 403 Forbidden
{"error":"The request comes from another site."}

The session cookie is also SameSite=Lax, so browsers do not attach it to cross-site POST requests in the first place. The server sends no CORS headers: the API is for same-origin pages and for non-browser clients.

Errors

Errors are JSON objects with an error text meant for people, sometimes with more fields.

Status When Body
400 The request is malformed. {"error":"The request is not valid JSON."}, {"error":"The command is missing."}, {"error":"The request contains a NUL character."}, {"error":"\"after\" must be a whole number."}, {"error":"Unknown demo person."}
401 Not signed in, or the sign-in is too old for a signature. {"error":"Sign in first.","code":"signin_required"} or {"error":"A signature needs a sign-in from the last 15 minutes. Sign in again, then approve.","code":"reauth_required","loginUrl":"/auth/login?fresh=1"}
403 CSRF rules, or a command reserved for the command line. see above; {"error":"\"import\" is only available from the command-line tool (see docs/operations.md)."}
404 The endpoint does not apply in this mode. {"error":"This is the demo: there is no server state. Set COUNTERSIGN_STORAGE to turn the server on."}, {"error":"Demo sign-in is off."}, {"error":"Not found."}
409 Six attempts to append lost against other writers. {"error":"Too many people are writing at once; try again."}
413 The body of /api/act is larger than 256 KiB. {"error":"The request is too large."}
422 The engine refused the command. The text is the engine's reason. {"error":"Four eyes: you wrote VH-0048 v1, so someone else has to approve it."}
502 Sign-in could not be started at the identity provider. {"error":"Sign-in could not be started: fetch failed"}
503 The server is misconfigured, the store is unreachable, or the audit trail has an integrity fault. {"error":"The server is not configured completely.","problems":["…"]}, or for writes during a fault {"error":"The audit trail failed its integrity check at entry #295 (…). Changes are refused until it has been investigated."}

Only code values are meant for programs: signin_required and reauth_required. Do not parse the error texts; they may be reworded. A 422 means "the rules say no" and will not succeed on retry without a change; a 409 can be retried as it is.

Health

GET /api/health

For monitoring. No sign-in. Described field by field in Operations.

{
  "status": "ok",
  "mode": "server",
  "version": "1.0.0",
  "storage": "sqlite",
  "auth": "demo",
  "entries": 295,
  "headHash": "38459ef97bf1090ff5fddbb56d45b6b7b87372806115179ad2173ebbca38e4d5",
  "headAt": "2026-11-11T11:23:26.304Z",
  "config": { "id": "fleet", "name": "Fleet configuration", "revision": 1, "hash": "547e2d8aea0405603c034b4fc1637fcc516e7ebbb59e5ad5509236947104cd8d" },
  "configProposal": null,
  "fault": null
}

configProposal is null, or the configuration change that waits for a second person: { "hash": "…", "by": "oskar", "at": "2026-11-11T15:04:45.907Z", "entry": 298 }.

Status status field
200 ok (in demo mode: {"status":"ok","mode":"demo","version":"1.0.0"})
503 misconfigured (with problems), store-unreachable (with the fixed error text The store could not be reached. See the server log.), integrity-fault (with fault)

Bootstrap

GET /api/bootstrap

What the application's pages ask first: which backend to use and who is signed in. No sign-in needed; with a session it also returns the person.

{
  "mode": "server",
  "version": "1.0.0",
  "storage": "sqlite",
  "auth": "demo",
  "user": { "id": "bob", "name": "Bob Okafor", "roles": ["coordinator"] },
  "authTime": "2026-11-11T11:23:04.706Z",
  "signMaxAgeMinutes": 0,
  "loginUrl": null,
  "demoPeople": [
    { "id": "alice", "name": "Alice Brandt", "roles": ["coordinator"] },
    { "id": "bob", "name": "Bob Okafor", "roles": ["coordinator"] }
  ],
  "demoConfig": { "schema": 1, "id": "fleet", "…": "…" },
  "configured": true
}
Field Meaning
mode demo (then only mode and version are present) or server.
storage, auth The adapter and sign-in method.
user The signed-in person (id, name, roles), or null.
authTime When that person authenticated, or null.
signMaxAgeMinutes The re-authentication limit for approvals; 0 means none.
loginUrl /auth/login with entra and oidc; null otherwise.
demoPeople With demo sign-in: the people who can be picked (all of them; shortened above): the preset's, or those of COUNTERSIGN_DEMO_USERS. null otherwise.
demoConfig With demo sign-in: the configuration in force (shortened above), so that the picker can show what each role may do before anyone is signed in. null otherwise.
configured Whether the store has a configuration. false means init has not been run.

Status 200; 503 with {"mode":"server","version":"1.0.0","problems":[…]} when settings are missing.

Entries

GET /api/entries

The audit trail, or the part of it after a given entry. Needs sign-in. Every signed-in person may read all of it.

Query parameter Meaning
after A whole number, default 0. Entries with seq greater than this are returned.
{
  "entries": [ { "seq": 290, "at": "2026-11-02T16:18:06.177Z", "actor": "felix", "…": "…" } ],
  "head": 290,
  "fault": null
}
Field Meaning
entries Audit entries in order. All of them after after: there is no page size.
head Number of the newest entry the server has verified.
fault null, or {"seq": N, "message": "…"} if the trail failed verification at entry N. entries then ends before it.

A client keeps the highest seq it has and polls with after=<that>; an empty entries means nothing is new. The application's pages do this every 15 seconds.

Status Body
200 as above
400 {"error":"\"after\" must be a whole number."}
401 {"error":"Sign in first.","code":"signin_required"}
404 demo mode
503 misconfigured

Act

POST /api/act

Runs one command as the signed-in person. Needs sign-in and the CSRF header.

Request:

{ "command": { "kind": "confirm", "id": "VH-0048" } }

Response 200: the audit entry that was written.

{ "entry": { "seq": 292, "at": "2026-11-11T11:23:26.209Z", "actor": "bob", "action": "confirm", "…": "…" } }

The server decides who is acting (the session), when (its clock) and whether it is allowed (the engine). Anything in the request that claims otherwise is ignored: there is no field for the actor or the time.

Status When
200 Accepted; exactly one entry was appended.
400 Not JSON, no command object with a kind, or the body contains a NUL character.
401 Not signed in (signin_required); or, for the signatures approve and approve-config with entra or oidc, the sign-in is older than COUNTERSIGN_SIGN_MAX_AGE_MINUTES (reauth_required, with loginUrl).
403 CSRF rules; or the command is import or configure.
413 Body over 256 KiB.
422 The engine refused; error says why. Nothing was written.
409 Lost the race six times; retry.
503 Integrity fault, misconfiguration.

On reauth_required, send the browser to loginUrl with a return parameter (/auth/login?fresh=1&return=/app), and repeat the request after the person has come back.

Each call is one command and one entry. There is no batch and no transaction across commands.

Login

GET /auth/login

Starts the OpenID Connect sign-in (entra, oidc). A browser navigation, not an API call.

Query parameter Meaning
return A path on this site to come back to, default /app. Anything that is not a local path is replaced by the default.
fresh 1: ask the provider to authenticate the person again (max_age=0, prompt=login). Used before an approval.
Status Meaning
302 To the provider's authorization endpoint. Sets the short-lived cookie countersign_signin (ten minutes).
302 With proxy or demo sign-in, or in demo mode: straight to the return path.
502 {"error":"Sign-in could not be started: <reason>"}: the provider's discovery document could not be read.
503 Misconfigured.

Callback

GET /auth/callback

The redirect URI registered at the provider: <COUNTERSIGN_PUBLIC_URL>/auth/callback. Exchanges the code, verifies the ID token, sets the session cookie.

Status Meaning
302 Signed in; to the path given as return at the start. Sets countersign_session, clears countersign_signin.
400 An HTML page "Sign-in failed" with the reason and a link to try again. The reasons are listed in Authentication.
404 {"error":"Not found."} when the sign-in method is not entra or oidc.

Logout

POST /auth/logout

Clears the session cookie. Needs the CSRF header; no body.

HTTP/1.1 200 OK
Set-Cookie: countersign_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0
{"ok":true}

It does not sign the person out of the identity provider, and it cannot invalidate a copy of the cookie: sessions are not stored on the server (see Authentication).

Demo sign-in

POST /auth/demo

Only with COUNTERSIGN_AUTH=demo. Signs in as one of the preset's people. Needs the CSRF header.

Request: {"id": "bob"}. Response 200: {"user":{"id":"bob","name":"Bob Okafor","roles":["coordinator"]}} and the session cookie.

Status Body
400 {"error":"Unknown demo person."}
403 CSRF rules
404 {"error":"Demo sign-in is off."} with any other sign-in method

Commands

The command object of POST /api/act. kind selects the command; the other properties depend on it. The rules each command must pass are in Architecture.

create

{
  "kind": "create",
  "type": "vehicle",
  "data": {
    "plate": "MZ-DX 411", "make": "Volta", "model": "Duo", "year": 2026, "fuel": "Electric",
    "inService": "2026-11-02", "leased": false, "classId": "VC-0005", "depotId": "DP-0004"
  },
  "note": "optional remark"
}

type is a record type of the configuration, definition or value. data holds the fields by key, with JSON types as described in Configuration: strings for text, dates (YYYY-MM-DD), selects and references (the referenced record's id), numbers for numbers, true/false for booleans. Leave optional fields out or send null or "". Unknown keys are refused. The new record's id is in the response: entry.recordId.

An option definition:

{
  "kind": "create",
  "type": "definition",
  "data": {
    "name": "Dashcam recording", "group": "Telematics", "kind": "selection", "default": "Off",
    "choices": ["Off", "Incidents only", "Continuous"], "unit": "", "min": null, "max": null,
    "description": "What the dashcam stores."
  }
}

All nine properties are required: choices is [] unless the kind is selection; min and max are numbers or null; unit and description are strings, possibly empty.

An option value:

{ "kind": "create", "type": "value", "data": { "definitionId": "OD-0008", "level": "depot", "targetId": "DP-0006", "value": 450 } }

level is the record type of the target. value has the JSON type of the option's kind.

edit

{ "kind": "edit", "id": "VH-0048", "data": { "…all fields…": "" }, "note": "Lease contract L-2307 signed." }

data is the complete new data, not a patch: fields you leave out become empty. For a draft (Created), note is optional and the draft is changed in place. For an approved record, note is the required reason (at least three characters) and the edit opens a new version that waits for approval. A Confirmed version cannot be edited; step it back first. For an option value, definitionId, level and targetId must stay as they are.

confirm

{ "kind": "confirm", "id": "VH-0048" }

approve

{ "kind": "approve", "id": "VH-0048", "meaning": "I have reviewed this change and approve it for use." }

meaning is required: the statement of what the signature means.

note, reason and meaning of any command must be text of at most 2000 characters (The reason must be text of at most 2000 characters.).

step-back

{ "kind": "step-back", "id": "VH-0048", "reason": "Contract number is wrong." }

retire and remove

{ "kind": "retire", "id": "VH-0041", "reason": "Sold at auction on 30 October." }
{ "kind": "remove", "id": "DP-0010", "reason": "Entered twice; the site belongs to the Harbour Depot." }

Reasons are at least three characters.

propose-config, approve-config, reject-config, withdraw-config

Changing the configuration takes two people who both hold the configure permission (see Configuration).

{ "kind": "propose-config", "config": { "…the whole configuration…": "" }, "reason": "CR-2026-051: record the VIN of every vehicle" }
{ "kind": "approve-config", "hash": "28ab5e7ea36c0427…", "meaning": "I have reviewed this configuration change and approve it." }
{ "kind": "reject-config", "hash": "28ab5e7ea36c0427…", "reason": "The VIN belongs on the registration, not here." }
{ "kind": "withdraw-config", "reason": "Superseded by CR-2026-052." }
Command Who Effect Entry
propose-config configure permission The document is stored as the waiting proposal. Refused if it is not valid, unchanged, contradicts existing records, or another proposal waits. after: { proposed: <SHA-256>, id, name }, reason
approve-config configure permission, not the proposer; a signature (recent sign-in) The proposal becomes the configuration in effect; v is the new revision. before: { hash }, after: { hash, id, name, proposedBy, proposedIn, reason, meaning }
reject-config configure permission, not the proposer The proposal is dropped. after: { rejected: <SHA-256> }, reason
withdraw-config the proposer The proposal is dropped. after: { withdrawn: <SHA-256> }

hash is the SHA-256 of the proposal being decided on: the after.proposed of its propose-config entry, also reported as configProposal.hash by Health. It makes sure that what is signed is what was read. All four entries have recordId: "CONFIG" and recordType: "config". A browser that replays the entries has the waiting proposal, with the whole proposed document, in its state.

$ curl -s -b oskar.jar -X POST $B/api/act "${H[@]}" -d @propose.json
{"entry":{"seq":298,"at":"…","actor":"oskar","actorName":"Oskar Lund","actorRoles":["system-owner"],"action":"propose-config","recordId":"CONFIG","recordType":"config","v":2,"before":{"hash":"547e2d8a…"},"after":{"proposed":"28ab5e7e…","id":"fleet","name":"Fleet configuration"},"reason":"CR-2026-051: record the VIN of every vehicle","command":{"…":""},"auth":{"method":"demo","authTime":"…"},"prevHash":"…","hash":"…"}}

$ curl -s -b oskar.jar -X POST $B/api/act "${H[@]}" -d '{"command":{"kind":"approve-config","hash":"28ab5e7e…","meaning":"Approved."}}'
{"error":"Four eyes: you proposed this configuration change, so someone else has to approve it."}

$ curl -s -b petra.jar -X POST $B/api/act "${H[@]}" -d '{"command":{"kind":"approve-config","hash":"28ab5e7e…","meaning":"Approved."}}'
{"entry":{"seq":299,"action":"approve-config","actor":"petra","recordId":"CONFIG","v":2,"…":""}}

import and configure

Refused over HTTP with status 403. For completeness, the shapes the command-line tool issues:

{ "kind": "import", "type": "region", "data": { "name": "West", "code": "WE" },
  "origin": { "system": "FleetBase 4", "id": "WE", "batch": "MIG-2026-11", "approvedBy": "R. Albers", "approvedAt": "2021-03-20" } }
{ "kind": "configure", "config": { "…the whole configuration…": "" }, "reason": "CR-2026-041: add the room to instruments",
  "reviewer": { "id": "kai", "name": "Kai Tanaka" } }

The audit entry

Returned by POST /api/act and GET /api/entries, written by export. Field by field in Architecture.

{
  "seq": 293,
  "at": "2026-11-11T11:23:26.256Z",
  "actor": "alice",
  "actorName": "Alice Brandt",
  "actorRoles": ["coordinator"],
  "action": "approve",
  "recordId": "VH-0048",
  "recordType": "vehicle",
  "v": 1,
  "before": { "state": "confirmed" },
  "after": { "state": "approved", "meaning": "I have reviewed this change and approve it for use." },
  "command": { "kind": "approve", "id": "VH-0048", "meaning": "I have reviewed this change and approve it for use." },
  "auth": { "method": "demo", "authTime": "2026-11-11T11:23:26.237Z" },
  "prevHash": "561538a3f0a6612a5ab94ddb854ab721f2033f46c456e184b019f01c452cfbef",
  "hash": "ba9667aeb411b3484f11c56c2b382676ab940cc71f13252662c67449300b6826"
}

before and after by action:

Action before after
create null the data
edit the previous data the new data
confirm {"state":"created"} {"state":"confirmed"}
approve {"state":"confirmed"} {"state":"approved","meaning":"…"}
step-back {"state":"approved","approvedBy":"…"} or {"state":"confirmed"} {"state":"confirmed"} or {"state":"created"}
retire, remove {"lifecycle":"active"} {"lifecycle":"retired"} / {"lifecycle":"removed"}
import null {"data":{…},"origin":{…}}
configure null or {"hash":"…"} {"hash":"…","id":"…","name":"…"}

reason is present when the command carried a reason or note. auth is present on entries written through the server or the command-line tool.

Walk-through with curl

This was run against a server with SQLite storage, demo sign-in and the fleet demo data. To reproduce it without the user interface, start the plain Node server that ships in scripts/ (it serves only /api/* and /auth/*):

export COUNTERSIGN_STORAGE=sqlite COUNTERSIGN_DATA_FILE=./scratch/api.sqlite
export COUNTERSIGN_AUTH=demo COUNTERSIGN_SESSION_SECRET="$(openssl rand -base64 48)"
export COUNTERSIGN_CONFIG=preset:fleet COUNTERSIGN_DEMO_DATA=1
node scripts/api-server.ts 3111
Countersign API on http://localhost:3111 (server mode)

npm run dev with the same variables serves the same API on port 3000, with the user interface.

In another terminal:

B=http://localhost:3111
H=(-H 'X-Countersign-Request: 1' -H 'Content-Type: application/json')

1. Is it up?

$ curl -s $B/api/health
{"status":"ok","mode":"server","version":"1.0.0","storage":"sqlite","auth":"demo","entries":290,"headHash":"1780d6e781351665621ddf081638c81c089441d8fa814422e8d4dc73879ff57d","headAt":"2026-11-02T16:18:06.177Z","config":{"id":"fleet","name":"Fleet configuration","revision":1,"hash":"547e2d8aea0405603c034b4fc1637fcc516e7ebbb59e5ad5509236947104cd8d"},"configProposal":null,"fault":null}

The 290 entries and their head hash are the fleet demo history; they are the same on every installation, because the demo data is deterministic.

2. Reading needs a sign-in.

$ curl -s -i $B/api/entries
HTTP/1.1 401 Unauthorized
{"error":"Sign in first.","code":"signin_required"}

3. Sign in as Bob, a Fleet Coordinator. Without the CSRF header the request is refused; with it, the session cookie is stored in a cookie jar.

$ curl -s -i -X POST $B/auth/demo -H 'Content-Type: application/json' -d '{"id":"bob"}'
HTTP/1.1 403 Forbidden
{"error":"The request is missing the X-Countersign-Request header."}

$ curl -s -i -c bob.jar -X POST $B/auth/demo "${H[@]}" -d '{"id":"bob"}'
HTTP/1.1 200 OK
Set-Cookie: countersign_session=eyJhY3RvciI6eyJpZCI6ImJvYiIsIm5hbWUiOiJCb2IgT2thZm9yIiwicm9sZXMiOlsiY29vcmRpbmF0b3IiXX0s…; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800
{"user":{"id":"bob","name":"Bob Okafor","roles":["coordinator"]}}

4. Read the newest entries.

$ curl -s -b bob.jar "$B/api/entries?after=289"
{"entries":[{"seq":290,"at":"2026-11-02T16:18:06.177Z","actor":"felix","actorName":"Felix Huber","actorRoles":["depot-manager"],"action":"create","recordId":"OV-0027","recordType":"value","v":1,"before":null,"after":{"definitionId":"OD-0008","level":"depot","targetId":"DP-0006","value":450},"command":{"kind":"create","type":"value","data":{"definitionId":"OD-0008","level":"depot","targetId":"DP-0006","value":450}},"prevHash":"d87e3bc8b33766111fdfebf7b85e3aa5103ac0b5ef539415a7b3cc48dd97ba6b","hash":"1780d6e781351665621ddf081638c81c089441d8fa814422e8d4dc73879ff57d"}],"head":290,"fault":null}

5. Create a vehicle. A Pool car (VC-0005) at the Rhine Depot (DP-0004).

$ curl -s -b bob.jar -X POST $B/api/act "${H[@]}" -d '{"command":{"kind":"create","type":"vehicle","data":{"plate":"MZ-DX 411","make":"Volta","model":"Duo","year":2026,"fuel":"Electric","inService":"2026-11-02","leased":false,"classId":"VC-0005","depotId":"DP-0004"}}}'
{"entry":{"seq":291,"at":"2026-11-11T11:23:26.147Z","actor":"bob","actorName":"Bob Okafor","actorRoles":["coordinator"],"action":"create","recordId":"VH-0048","recordType":"vehicle","v":1,"before":null,"after":{"plate":"MZ-DX 411","make":"Volta","model":"Duo","year":2026,"fuel":"Electric","inService":"2026-11-02","leased":false,"classId":"VC-0005","depotId":"DP-0004"},"command":{"kind":"create","type":"vehicle","data":{"plate":"MZ-DX 411","make":"Volta","model":"Duo","year":2026,"fuel":"Electric","inService":"2026-11-02","leased":false,"classId":"VC-0005","depotId":"DP-0004"}},"auth":{"method":"demo","authTime":"2026-11-11T11:23:04.706Z"},"prevHash":"1780d6e781351665621ddf081638c81c089441d8fa814422e8d4dc73879ff57d","hash":"8c8f64b1e150a1d0e7b83b68728d3c5dfa39af22b08f61dbd841237ef03720c4"}}

The record is VH-0048, version 1, Created. The engine's refusals arrive as 422 with the reason:

$ curl -s -i -b bob.jar -X POST $B/api/act "${H[@]}" -d '{"command":{"kind":"create","type":"vehicle","data":{"plate":"MZ-DX 411","make":"Volta","model":"Duo","year":1985,"fuel":"Electric","classId":"VC-0005","depotId":"DP-0004"}}}'
HTTP/1.1 422 Unprocessable Entity
{"error":"Year: the smallest allowed value is 1990."}

$ curl -s -b bob.jar -X POST $B/api/act "${H[@]}" -d '{"command":{"kind":"create","type":"region","data":{"name":"East","code":"EA"}}}'
{"error":"As Fleet Coordinator you can't create a region."}

6. Confirm it.

$ curl -s -b bob.jar -X POST $B/api/act "${H[@]}" -d '{"command":{"kind":"confirm","id":"VH-0048"}}'
{"entry":{"seq":292,"at":"2026-11-11T11:23:26.209Z","actor":"bob","actorName":"Bob Okafor","actorRoles":["coordinator"],"action":"confirm","recordId":"VH-0048","recordType":"vehicle","v":1,"before":{"state":"created"},"after":{"state":"confirmed"},"command":{"kind":"confirm","id":"VH-0048"},"auth":{"method":"demo","authTime":"2026-11-11T11:23:04.706Z"},"prevHash":"8c8f64b1e150a1d0e7b83b68728d3c5dfa39af22b08f61dbd841237ef03720c4","hash":"561538a3f0a6612a5ab94ddb854ab721f2033f46c456e184b019f01c452cfbef"}}

7. Bob cannot approve his own record.

$ curl -s -i -b bob.jar -X POST $B/api/act "${H[@]}" -d '{"command":{"kind":"approve","id":"VH-0048","meaning":"I have reviewed this change and approve it for use."}}'
HTTP/1.1 422 Unprocessable Entity
{"error":"Four eyes: you wrote VH-0048 v1, so someone else has to approve it."}

8. Alice, another coordinator, signs in and approves.

$ curl -s -c alice.jar -X POST $B/auth/demo "${H[@]}" -d '{"id":"alice"}'
{"user":{"id":"alice","name":"Alice Brandt","roles":["coordinator"]}}

$ curl -s -b alice.jar -X POST $B/api/act "${H[@]}" -d '{"command":{"kind":"approve","id":"VH-0048","meaning":"I have reviewed this change and approve it for use."}}'
{"entry":{"seq":293,"at":"2026-11-11T11:23:26.256Z","actor":"alice","actorName":"Alice Brandt","actorRoles":["coordinator"],"action":"approve","recordId":"VH-0048","recordType":"vehicle","v":1,"before":{"state":"confirmed"},"after":{"state":"approved","meaning":"I have reviewed this change and approve it for use."},"command":{"kind":"approve","id":"VH-0048","meaning":"I have reviewed this change and approve it for use."},"auth":{"method":"demo","authTime":"2026-11-11T11:23:26.237Z"},"prevHash":"561538a3f0a6612a5ab94ddb854ab721f2033f46c456e184b019f01c452cfbef","hash":"ba9667aeb411b3484f11c56c2b382676ab940cc71f13252662c67449300b6826"}}

VH-0048 version 1 is now in effect.

9. Change the approved record. The edit needs a reason and opens version 2, which waits for approval while version 1 stays in effect. Then Bob, who may approve vehicles, steps it back with a reason.

$ curl -s -b alice.jar -X POST $B/api/act "${H[@]}" -d '{"command":{"kind":"edit","id":"VH-0048","data":{"plate":"MZ-DX 411","make":"Volta","model":"Duo","year":2026,"fuel":"Electric","inService":"2026-11-02","leased":true,"classId":"VC-0005","depotId":"DP-0004"},"note":"Lease contract L-2307 signed."}}'

The entry returned: #294 edit VH-0048 v2, reason "Lease contract L-2307 signed."

$ curl -s -b bob.jar -X POST $B/api/act "${H[@]}" -d '{"command":{"kind":"step-back","id":"VH-0048","reason":"Contract number is wrong."}}'

The entry returned: #295 step-back VH-0048 v2, before {"state":"confirmed"}, after {"state":"created"}.

10. Other refusals.

$ curl -s -i -b bob.jar -X POST $B/api/act "${H[@]}" -d '{"command":{"kind":"configure","config":{},"reason":"x"}}'
HTTP/1.1 403 Forbidden
{"error":"\"configure\" is only available from the command-line tool. Over HTTP a configuration change is proposed (\"propose-config\") and approved by a second person (\"approve-config\"); see docs/configuration.md."}

$ curl -s -i -b bob.jar -X POST $B/api/act "${H[@]}" -d '{"command":'
HTTP/1.1 400 Bad Request
{"error":"The request is not valid JSON."}

$ curl -s -i -b bob.jar -X POST $B/api/act "${H[@]}" -d '{"command":{"kind":"confirm","id":"VH-9999"}}'
HTTP/1.1 422 Unprocessable Entity
{"error":"Record VH-9999 does not exist."}

$ curl -s -i -b bob.jar -X POST $B/api/act "${H[@]}" -H 'Origin: https://evil.example' -d '{"command":{"kind":"confirm","id":"VH-0001"}}'
HTTP/1.1 403 Forbidden
{"error":"The request comes from another site."}

11. Sign out, and look at the result.

$ curl -s -i -b bob.jar -X POST $B/auth/logout "${H[@]}"
HTTP/1.1 200 OK
Set-Cookie: countersign_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0
{"ok":true}

$ curl -s $B/api/health
{"status":"ok","mode":"server","version":"1.0.0","storage":"sqlite","auth":"demo","entries":295,"headHash":"38459ef97bf1090ff5fddbb56d45b6b7b87372806115179ad2173ebbca38e4d5","headAt":"2026-11-11T11:23:26.304Z","config":{"id":"fleet","name":"Fleet configuration","revision":1,"hash":"547e2d8aea0405603c034b4fc1637fcc516e7ebbb59e5ad5509236947104cd8d"},"configProposal":null,"fault":null}

Five commands, five entries: 291 to 295. Stop the server with Ctrl+C; the trail is in ./scratch/api.sqlite and can be inspected with npm run cli -- status, verify and export.

Using the API from your own programs

  • Reading is the simplest integration: sign in (or come through the proxy), fetch /api/entries?after=N periodically, and process new entries. Each entry says what changed, on which record, by whom. For the current state of records, replay the entries with the engine (replay in lib/engine.ts, which also verifies them) or take export --what records from the command line.
  • Writing from a program means acting as a person: every command is attributed to the signed-in identity and subject to the same rules, including four eyes. With entra and oidc there is no non-interactive sign-in. A service that must write needs the proxy method (the proxy authenticates the service and passes an identity for it) and a role in the configuration. Consider carefully whether an automated actor fits your procedures; an automated approver defeats the purpose of the system.
  • Always send X-Countersign-Request: 1 on POST requests.
  • Handle 401 reauth_required on approvals (of records and of configuration changes), 409 by retrying, 422 by showing the message to a person, 503 by stopping.
  • Do not depend on error texts or on the order of JSON keys.

Mounting the handlers in another server

The HTTP handlers are not tied to Next.js. server/app.ts exports a class whose methods take a standard Request and return a standard Response:

import { defaultApp } from "./server/app.ts";

const app = defaultApp();      // one per process, built from the COUNTERSIGN_* environment
app.bootstrap(req)  app.entries(req)  app.act(req)  app.health()
app.login(req)      app.callback(req) app.logout(req) app.demoLogin(req)

In the Next.js application each route file is three lines (export const POST = (req: Request) => defaultApp().act(req)). scripts/api-server.ts is the same thing for Node's own http module, in about fifty lines: it maps method and path to a handler, converts the incoming request to a Request, and copies status, headers (with multiple Set-Cookie headers kept separate) and body back. Use it

  • to try the API with curl without building the user interface, as in the walk-through;
  • in integration tests of systems that talk to Countersign;
  • as the template for mounting the API in Express, Fastify, Hono or any other server.
npm run api -- 3111                     # the same as: node scripts/api-server.ts 3111
node scripts/api-server.ts [port]       # port from the argument, else PORT, else 3000

It serves the eight routes of this page and answers everything else with 404 {"error":"Not found."}. It has no user interface, no TLS and no rate limiting; in production put it behind the same kind of reverse proxy as the Next.js application (Deployment). If you mount the handlers yourself, keep three things: run on Node.js (the stores and the session code need it), never cache the responses, and pass the request's real Host, Origin, Cookie and forwarded headers through unchanged, because the origin check and the redirect URI are computed from them.