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
- Authentication
- CSRF protection
- Errors
- Health:
GET /api/health - Bootstrap:
GET /api/bootstrap - Entries:
GET /api/entries - Act:
POST /api/act - Login:
GET /auth/login - Callback:
GET /auth/callback - Logout:
POST /auth/logout - Demo sign-in:
POST /auth/demo - Commands
- The audit entry
- Walk-through with curl
- Using the API from your own programs
- Mounting the handlers in another server
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:
- 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. - If it carries an
Originheader, that header equals the application's own address:COUNTERSIGN_PUBLIC_URL, or else the address derived fromX-Forwarded-Proto,X-Forwarded-HostandHost. Requests without anOriginheader (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=Nperiodically, 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 (replayinlib/engine.ts, which also verifies them) or takeexport --what recordsfrom 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
entraandoidcthere is no non-interactive sign-in. A service that must write needs theproxymethod (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: 1on POST requests. - Handle 401
reauth_requiredon 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.