Skip to content

State endpoints

State endpoints are the only ones that mutate persistent data. They use optimistic concurrency, return Cache-Control: no-store, and never leak the bearer token.

Create

POST /api/v1/state

Mints a new anonymous plan. Request body is empty or an initial state document. Response body contains:

{
"data": {
"state_token": "<base64url ≥256 bits>",
"state_version": 0,
"catalog_version_id": "math-2026-spring"
},
"meta": { ... },
"warnings": [],
"unknowns": [],
"source_references": []
}

The state_token is returned exactly once. Subsequent endpoints require it as Authorization: Bearer <token>. See Authentication.

Read

GET /api/v1/state/current
Authorization: Bearer <token>

Returns the current state document and version. Cache-Control: no-store.

Patch

PATCH /api/v1/state/current
Authorization: Bearer <token>
Content-Type: application/json

Applies a structured set of changes. Body:

{
"expected_state_version": 7,
"operations": [
{ "op": "add_course", "term_id": "fall-2026", "course_code": "MATH 247", "status": "planned" },
{ "op": "remove_course", "term_id": "winter-2026", "course_code": "STAT 230" }
]
}

Returns the new state and version. 409 Conflict if expected_state_version doesn’t match — re-read and retry.

Replace

PUT /api/v1/state/current
Authorization: Bearer <token>
Content-Type: application/json

Replaces the full state document (typically used to re-import an export). Body is a complete state document plus expected_state_version. Same 409 Conflict semantics as PATCH.

Export

GET /api/v1/state/current/export
Authorization: Bearer <token>

Returns a portable JSON copy of the state with Cache-Control: no-store. Does not write a state-event row.

You can re-import an export later via PUT /api/v1/state/current.

Migration preview

POST /api/v1/state/current/migration-preview
Authorization: Bearer <token>
Content-Type: application/json

Body:

{
"target_catalog_version_id": "math-2026-fall"
}

Returns a diff: which courses were renamed, which requirements changed, which entries become invalid. Does not apply the migration — preview only. To apply, follow with PUT /api/v1/state/current with the migrated state.

See ADR 0004.

Delete

DELETE /api/v1/state/current
Authorization: Bearer <token>
Content-Type: application/json

Body:

{ "confirm": true }

The explicit confirmation is required — DELETE without confirm: true returns 400 bad_request. On success:

  • The state row is removed.
  • The HMAC verifier is removed.
  • A minimal tombstone is recorded (no academic content).
  • Subsequent requests with the same token return 401 unauthorized, indistinguishable from any other unknown token.

There is no soft delete and no recovery path. (ADR 0019)

Errors

StatusCodeCause
400missing_confirmDELETE without { "confirm": true }.
401unauthorizedToken did not match a stored verifier.
401missing_tokenAuthorization header absent on a state endpoint.
403token_in_queryToken supplied via query parameter (always rejected).
409state_version_conflictexpected_state_version mismatch.
422catalog_version_mismatchBody’s catalog_version_id doesn’t match the persisted state’s.

Concurrency

State mutations serialize through a WriteCoordinator queue (ADR 0027). Under load:

  • Reads run lock-free on SQLite WAL.
  • Writes drain through a single in-process goroutine with a bounded queue (queue depth 64).
  • Per-IP rate limits cap abuse; configurable via UWSCRAPE_RATE_LIMIT_STATE_PER_IP_PER_MINUTE and UWSCRAPE_RATE_LIMIT_STATE_PER_IP_BURST.

Want more?