# riskregister.com: the docs in full > riskregister.com is a system of record for risk registers. AI assistants read and change registers through its MCP endpoint and REST API, acting as the person whose token they use. A whole register an assistant saves, or starts from a template, lands as a draft, and a change sent as a document as a proposal, for a person to review in the app. A register an assistant has riskregister.com's own AI generate follows its access: a draft with Ask me first, a working register with Make changes; Read only can't generate one. With Make changes, the single-record tools change a register directly too. Every change is in the audit log. --- Source: https://riskregister.com/docs/overview.md # Public REST API The riskregister.com REST API lets you read every risk, control, scope, control implementation, register, and audit row in your organisation, and create or modify them programmatically. The same API powers our published SDK and the app's [MCP endpoint](https://riskregister.com/docs/mcp.md). This page is the API reference hub. Start with [Getting started](https://riskregister.com/docs/getting-started.md) if you've never called the API before. ## At a glance | Property | Value | | --- | --- | | Base URL | `https://riskregister.com` | | All paths | Prefixed with `/api/v1/` | | Authentication | Bearer token in the `Authorization` header — either a [personal access token](https://riskregister.com/docs/authentication.md#personal-access-tokens) (`rr_pat_live_*`, every plan) or an [organisation API key](https://riskregister.com/docs/authentication.md#organisation-api-keys) (`rrk_live_*`, Business+) | | Wire format | JSON request and response. UTF-8. | | Schema | [`docs/api/openapi.yaml`](https://riskregister.com/docs/api/openapi.yaml) — hand-written OpenAPI 3.0.3 spec | | Idempotency | Optional `Idempotency-Key` header on every mutation — Stripe-compatible | | Concurrency | Opt-in `ETag`, echoed back in the `x-rr-if-match` header, for optimistic concurrency (not the standard `If-Match`: see the guide) | | Pagination | Cursor-based on every list endpoint | | Errors | Stable typed envelope with discriminated codes | | Rate limits | Per-token: 1000 reads/min, 100 mutations/min | | Webhooks | HMAC-SHA256 signed outbound events ([webhooks docs](https://riskregister.com/docs/webhooks.md)) | ## SDK ```bash npm install @riskregister/sdk ``` Package: [`@riskregister/sdk` on npm](https://www.npmjs.com/package/@riskregister/sdk). ```ts import { Client } from '@riskregister/sdk'; const client = new Client({ token: process.env.RISKREGISTER_TOKEN! }); const { data } = await client.GET('/api/v1/registers'); for (const register of data.data) console.log(register.registerRef, register.name); ``` The client takes either token shape: a personal access token or an organisation API key. ## MCP endpoint The app serves MCP itself: 39 tools at `https://mcp.riskregister.com/mcp` (for a token that may write; fewer for a read or propose token) over the workspace and its people, registers and their documents, what changed and what is due, exports, templates, saving a register as a draft and proposing changes to one, risks (a search across registers, and the risk verbs: assess, treat, review), controls and their implementations, scopes and reference data, and all 43 at `/mcp/full`, which adds the four admin tools for webhooks and the audit log. Each tool calls the REST route it names, so it gets exactly this API's checks. It authenticates with a personal access token or an organisation API key. See [MCP endpoint](https://riskregister.com/docs/mcp.md). The standalone `@riskregister/mcp-server` package is being retired in favour of the endpoint. ## Capabilities | You want to… | Read | Mutate | | --- | --- | --- | | **Registers** | `GET /api/v1/registers`, `GET /api/v1/registers/{id}` | `POST /api/v1/registers`, `PATCH /api/v1/registers/{id}`, `POST /api/v1/registers/{id}/archive`, `POST /api/v1/registers/from-context` (AI generation) | | **Risks** | `GET /api/v1/registers/{id}/risks`, `GET /api/v1/risks/{id}` | `POST /api/v1/registers/{id}/risks`, `PATCH /api/v1/risks/{id}`, `DELETE /api/v1/risks/{id}` | | **Risk verbs** | `GET /api/v1/risks/{id}/reviews` | `POST /api/v1/risks/{id}/{assess,treat,request-review,complete-review,cancel-review}` | | **Controls** | `GET /api/v1/controls`, `GET /api/v1/controls/{id}` | `POST /api/v1/controls`, `PATCH /api/v1/controls/{id}`, `DELETE /api/v1/controls/{id}` | | **Control implementations** | `GET /api/v1/controls/{id}/implementations`, `GET /api/v1/control-implementations/{id}` | `POST/PATCH/DELETE` (Business+ via `scopes` feature flag) | | **Risk-control mappings** | Each risk's linked controls are in `GET /api/v1/registers/{id}/document` and `GET /api/v1/registers/{id}/coverage` (there is no per-risk controls list) | `PUT /api/v1/risks/{riskId}/controls/{controlId}`, `DELETE` | | **Scopes** | `GET /api/v1/scopes`, `GET /api/v1/scopes/{id}` | `POST/PATCH/DELETE` (Business+) | | **Risk categories** | `GET /api/v1/risk-categories` | UI-only today (no v1 mutation surface) | | **Audit log** | `GET /api/v1/audit-log` (admin-only) | Append-only by trigger; no mutation surface | | **Webhook endpoints** | `GET /api/v1/webhook-endpoints`, `GET .../{id}`, `GET .../{id}/deliveries` (all admin-only) | Cookie-auth management UI today | | **Reviews** | `GET /api/v1/reviews/{reviewId}` | Through the risk-verb endpoints | The full path list with request/response schemas lives in [openapi.yaml](https://riskregister.com/docs/api/openapi.yaml). Validate with `npx redocly lint`; consume with any OpenAPI 3.0 client generator. ## Cross-cutting contracts | Contract | Reference | | --- | --- | | Bearer authentication, PAT vs org-key | [Authentication](https://riskregister.com/docs/authentication.md) | | Idempotency-Key on retries | [Idempotency](https://riskregister.com/docs/idempotency.md) | | ETag + `x-rr-if-match` for concurrent edits | [ETag and optimistic concurrency](https://riskregister.com/docs/etag-concurrency.md) | | Cursor pagination on list endpoints | [Pagination](https://riskregister.com/docs/pagination.md) | | Stable error envelope | [Errors](https://riskregister.com/docs/errors.md) | | Per-token + per-IP rate limits | [Rate limits](https://riskregister.com/docs/rate-limits.md) | | Outbound webhooks (HMAC signing, retries) | [Webhooks](https://riskregister.com/docs/webhooks.md) | ## Stable identifiers Every entity has a human-readable per-org ref (`RISK-0042`, `CTRL-0042`, `SCOPE-0042`, `IMPL-0042`, `REG-0042`) alongside its UUID. Every path parameter accepts either: ``` GET /api/v1/risks/RISK-0042 GET /api/v1/risks/8f3a2b1c-7d9e-4f0a-b3c5-d2e6f7a8b9c0 # same row ``` Refs are case-insensitive on input (the resolver upper-cases them) and zero-padded to 4 digits minimum (`RISK-0001`, not `RISK-1`). The pattern is `^[A-Z]+-\d+$`. See ADR-009. ## DTO conventions Every response wraps data in `{ data, requestId }` (or `{ data, pagination, requestId }` for paginated lists). DTOs follow the public projection — internal UUIDs that lock the contract to our database (like `orgId`, `createdBy` on the registers DTO) are stripped. Field naming is camelCase. Timestamps are ISO 8601 strings with millisecond precision and `Z` suffix. Score fields (`inherentLikelihood`, `inherentImpact`, `residualLikelihood`, `residualImpact`) range 1–N where N depends on the parent register's `scoringMatrixKind` (5 for `iso31000_5x5`, 3 for `simple_3x3`). ## Versioning + deprecation policy The current version is `v1`. A `v2` would commit to: - A 12-month overlap window where both versions are live. - A 6-month `Sunset` header notice via RFC 8594 before `v1` is removed. - The exact deprecation timeline announced in the CHANGELOG and via direct email to every customer with active API traffic. Within `v1`: - New fields on responses are additive. SDK consumers using TypeScript narrowing won't break. - New optional request parameters are additive. - New error codes are additive. - New event types on webhooks are additive. - Breaking changes go in `v2`. ## CORS + browser usage The API does not emit CORS headers. It's server-to-server only. Calling from a browser will fail at the CORS preflight. To build a browser app on top of riskregister.com, sign requests on your server and proxy from the browser. ## Status codes | Code | Meaning | | --- | --- | | 200 | Success | | 201 | Resource created | | 204 | Success, no body (DELETE) | | 400 | Validation error | | 401 | Authentication failed | | 403 | Authentication OK, forbidden by tier / role / suspension | | 404 | Entity doesn't exist or isn't visible to this caller | | 405 | Method not allowed (e.g. `DELETE /api/v1/registers/{id}` — use `/archive` instead) | | 409 | Conflict — state machine violation or idempotency body mismatch | | 412 | Precondition failed — stale `x-rr-if-match` | | 413 | Payload too large | | 429 | Rate limited — honour `Retry-After` | | 500 | Internal server error — quote `requestId` | | 503 | `EXTERNAL_SERVICE_ERROR`: an upstream dependency failed, retry with backoff. `SERVICE_UNAVAILABLE`: paused on purpose (the MCP endpoint's switch), try again later, not in a loop | Every error response carries the [stable error envelope](https://riskregister.com/docs/errors.md). ## Common workflows | Workflow | Recipe | | --- | --- | | **First integration** | Mint a PAT → `curl GET /registers` → create a risk via POST → confirm with GET. [Getting started tutorial](https://riskregister.com/docs/getting-started.md). | | **AI-generated register** | `POST /api/v1/registers/from-context` with a description; receive the register, every generated risk inline, and a `reviewUrl`. With a `write` token or an organisation key the register is live, and `register.created` and `risk.created` go to webhooks; with a `propose` token it is a draft, which sends them only when a person adopts it in the app. | | **Bulk import** | Today: cookie-auth `/import/parse` + `/import/commit`. Bearer-API import isn't on the v1 surface yet. | | **Concurrent-edit-safe update** | GET the resource → capture `ETag` → PATCH with `x-rr-if-match` → handle 412 by re-reading + re-applying. [ETag reference](https://riskregister.com/docs/etag-concurrency.md). | | **Idempotent retry on POST** | Generate a UUID → POST with `Idempotency-Key` header → retry with the same key on transport failure. Same body returns the original result; different body returns 409. [Idempotency reference](https://riskregister.com/docs/idempotency.md). | | **Receive event push** | Configure a webhook endpoint (admin UI), verify signatures on every delivery. [Webhooks](https://riskregister.com/docs/webhooks.md). | | **Resume long-running pagination** | Forward `next_cursor` from the prior response on subsequent requests. Cursors don't expire. [Pagination](https://riskregister.com/docs/pagination.md). | ## Support - Status page: [status.riskregister.com](https://status.riskregister.com) - Status quoting: quote the `requestId` from any error envelope when filing a ticket - Contact: [riskregister.com/contact](https://riskregister.com/contact), or support@riskregister.com ## See also - [openapi.yaml](https://riskregister.com/docs/api/openapi.yaml) — the machine-readable spec. - [Webhooks](https://riskregister.com/docs/webhooks.md) — outbound event delivery. --- Source: https://riskregister.com/docs/getting-started.md # Getting started Your first call against the riskregister.com REST API. Curl-based, ~5 minutes from zero to a working risk created via the API. By the end of this you'll have: 1. Minted a personal access token. 2. Listed the registers in your org. 3. Created a new risk in a register. 4. Verified the risk by reading it back. 5. Practised an idempotent retry. You'll need: a riskregister.com account, command-line `curl`, command-line `jq` (optional, for pretty-printing). Plan tier doesn't matter — personal access tokens are available on every plan including Free. ## Step 1: Mint a personal access token In the app, navigate to **Personal access tokens** in your user menu (or visit `/account/personal-access-tokens` directly). Click **Create token**, give it a memorable name (e.g. "Curl tutorial"), keep **Make changes** (step 3 creates a risk directly, which **Ask me first** doesn't allow), and click **Create**. Copy the plaintext token immediately. It's shown **once** and never again. If you lose it, revoke and create a new one — the existing one is unrecoverable. A PAT looks like: ``` rr_pat_live_<43 base64url characters> ``` For the rest of this tutorial, export it as an environment variable so the examples are copy-pasteable: ```bash export RR_PAT="rr_pat_live_..." ``` ## Step 2: List your registers ```bash curl -s https://riskregister.com/api/v1/registers \ -H "Authorization: Bearer $RR_PAT" | jq . ``` You should see something like: ```json { "data": [ { "id": "8f3a2b1c-7d9e-4f0a-b3c5-d2e6f7a8b9c0", "registerRef": "REG-0001", "name": "Information security risks", "slug": "information-security", "type": "enterprise", "status": "active", "scoringMatrixKind": "iso31000_5x5", "reviewCadence": "quarterly", "createdAt": "2026-05-15T10:00:00.000Z", "updatedAt": "2026-05-28T12:00:00.000Z" } ], "pagination": { "next_cursor": null, "has_more": false }, "requestId": "..." } ``` If your org has no registers yet, `data` is empty. Create one through the app first. Note the `registerRef` (`REG-0001`). Every entity has a human-readable per-org ref alongside its UUID. You can use either at every path that takes an id — they resolve to the same row. The rest of this tutorial uses refs because they're easier to read. ## Step 3: Create a risk Pick a register ref from the list above. Every risk also needs a category, given by its UUID. List the categories your organisation can use and copy one's `id`: ```bash curl -s https://riskregister.com/api/v1/risk-categories \ -H "Authorization: Bearer $RR_PAT" | jq '.data[] | {id, name}' ``` Now POST a risk, with that `id` in place of ``: ```bash curl -s -i -X POST https://riskregister.com/api/v1/registers/REG-0001/risks \ -H "Authorization: Bearer $RR_PAT" \ -H "Content-Type: application/json" \ -d '{ "title": "Lost laptop with customer data", "description": "An employee laptop containing customer data is lost or stolen.", "categoryId": "", "inherentLikelihood": 3, "inherentImpact": 5, "residualLikelihood": 2, "residualImpact": 4, "treatment": "mitigate", "status": "open" }' ``` A few things to notice: - `-i` shows response headers — useful for the next step. - `categoryId` is a category's UUID, from `GET /api/v1/risk-categories`. A name or slug is refused with `400 VALIDATION_ERROR`. - Score fields range 1–5 on the default `iso31000_5x5` matrix. On a `simple_3x3` matrix register, the same fields range 1–3. - `treatment` is one of `mitigate`, `accept`, `transfer`, `avoid`. `status` is `open`, `under_review` or `closed`. A risk becomes `accepted` only through a formal acceptance, `POST /api/v1/risks/{id}/accept`, which records the rationale and any expiry; setting `accepted` any other way returns `400` with subcode `ACCEPTANCE_REQUIRED`. Expected response (truncated): ``` HTTP/1.1 201 Created ETag: W/"0000019018a5e3c2" X-RateLimit-Limit-Mutations: 100 Content-Type: application/json { "data": { "id": "5dba8a3c-9b71-4f9b-9b9a-3b8b2a1c4d2e", "riskRef": "RISK-0042", "title": "Lost laptop with customer data", "...": "..." }, "requestId": "..." } ``` Save the `riskRef` and the `ETag`: ```bash RISK_REF="RISK-0042" RISK_ETAG='W/"0000019018a5e3c2"' ``` ## Step 4: Read it back ```bash curl -s https://riskregister.com/api/v1/risks/$RISK_REF \ -H "Authorization: Bearer $RR_PAT" | jq . ``` The response is the same DTO shape as the POST response, wrapped in `{ data, requestId }`. The server emits an `ETag` header on every single-resource GET. You can use it to make a future PATCH safe against concurrent edits — see [ETag and optimistic concurrency](https://riskregister.com/docs/etag-concurrency.md). ## Step 5: Practise an idempotent retry Real-world API calls fail. A network timeout on POST leaves you guessing whether the risk was created. Without idempotency, retrying blindly creates duplicates. The fix: supply an `Idempotency-Key` header on the first call. Then retry with the same key on failure. Same key plus same body returns the original result; same key plus different body returns 409. Try it: ```bash KEY=$(uuidgen) # First attempt curl -s -X POST https://riskregister.com/api/v1/registers/REG-0001/risks \ -H "Authorization: Bearer $RR_PAT" \ -H "Idempotency-Key: $KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Phishing attack via spoofed vendor email", "categoryId": "", "inherentLikelihood": 4, "inherentImpact": 5, "residualLikelihood": 2, "residualImpact": 3, "treatment": "mitigate", "status": "open" }' | jq '.data.riskRef' # → "RISK-0043" # Retry with the same key + same body (simulating a network retry) curl -s -X POST https://riskregister.com/api/v1/registers/REG-0001/risks \ -H "Authorization: Bearer $RR_PAT" \ -H "Idempotency-Key: $KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Phishing attack via spoofed vendor email", "categoryId": "", "inherentLikelihood": 4, "inherentImpact": 5, "residualLikelihood": 2, "residualImpact": 3, "treatment": "mitigate", "status": "open" }' | jq '.data.riskRef' # → "RISK-0043" (same ref! the second call replayed the first) ``` You created exactly one risk. The second call returned the original outcome without creating a duplicate. What happens if you retry with a different body? Try changing `"title"` and re-sending: ```bash curl -s -X POST https://riskregister.com/api/v1/registers/REG-0001/risks \ -H "Authorization: Bearer $RR_PAT" \ -H "Idempotency-Key: $KEY" \ -H "Content-Type: application/json" \ -d '{"title":"Different title","...": "..."}' | jq . # → 409 with details.subcode = "API_V1_IDEMPOTENCY_BODY_MISMATCH" ``` This is the safety net. If your retry logic accidentally mutates the body between attempts, the server tells you instead of silently creating something you didn't expect. ## What you built You authenticated against the API with a personal access token, listed your registers, created a risk, read it back, and proved that idempotent retries do the right thing. ## Next steps | Topic | Read next | | --- | --- | | Update a risk safely under concurrent edits | [ETag and optimistic concurrency](https://riskregister.com/docs/etag-concurrency.md) | | Receive push events when risks change | [Webhooks](https://riskregister.com/docs/webhooks.md) | | Paginate through a large register | [Pagination](https://riskregister.com/docs/pagination.md) | | Use the typed TypeScript SDK | [`@riskregister/sdk` on npm](https://www.npmjs.com/package/@riskregister/sdk) | | Drive the API from an AI assistant (Claude Code, Cursor) | [MCP endpoint](https://riskregister.com/docs/mcp.md) | | Generate a register from a description | `POST /api/v1/registers/from-context` — see [openapi.yaml](https://riskregister.com/docs/api/openapi.yaml) | | Filter risks by status, score band, owner | `GET /api/v1/registers/{id}/risks?status=open&score_band=critical` — see [openapi.yaml](https://riskregister.com/docs/api/openapi.yaml) | | Assess or treat a risk with operator rationale | `POST /api/v1/risks/{id}/{assess,treat}` — captured as first-class verb events | | Use organisation API keys instead of PATs | [Authentication §Organisation API keys](https://riskregister.com/docs/authentication.md#organisation-api-keys) | ## Troubleshooting **401 Unauthorized** — the token is missing, malformed, revoked, or hash-mismatched. All five failure modes look identical from the outside. Double-check the `Authorization: Bearer ` prefix and the token's trailing characters. A message saying the token expired on a date means just that: make a new one under Personal access tokens. **403 Forbidden saying personal access tokens are disabled** — personal access tokens are switched off for your organisation. Contact your admin. (This refusal carries a message, not a subcode.) **403 Forbidden with subcode `ORG_SUSPENDED`** — your organisation has been suspended by a platform operator. Contact your admin. **429 Too Many Requests** — you've hit the rate limit. The response includes `Retry-After: `. Honour it. See [Rate limits](https://riskregister.com/docs/rate-limits.md). **404 Not Found** — the entity doesn't exist, doesn't belong to your org (we never leak cross-tenant existence), or has been deleted. Verify the ref or UUID with `GET /api/v1/registers` first. **409 Conflict with subcode `API_V1_IDEMPOTENCY_BODY_MISMATCH`** — you reused an `Idempotency-Key` with a different body. Generate a fresh key. **500 Internal Server Error** — something we didn't expect. Quote the `requestId` from the error envelope when filing a ticket. For the full error surface, see [Errors](https://riskregister.com/docs/errors.md). --- Source: https://riskregister.com/docs/mcp.md # MCP endpoint The app serves the [Model Context Protocol](https://modelcontextprotocol.io) itself, so an AI assistant can read and work on your registers with the same checks as the REST API. Each MCP tool calls the REST route it names, in-process: a tool call **is** the REST call, with its validation, permissions, plan limits, rate limits, idempotency, concurrency check, webhooks and audit trail. | Property | Value | | --- | --- | | URL | `https://mcp.riskregister.com/mcp`, the endpoint's own host. `https://riskregister.com/mcp` is the same endpoint on the app's host, and keeps working | | Profiles | `/mcp`: every tool but the admin ones. `/mcp/full`: all 43 | | Transport | Streamable HTTP, stateless: spec revision 2026-07-28, and 2025-era clients (`initialize`) on the same URL. No sessions, no batches, no `subscriptions/listen` | | Authentication | `Authorization: Bearer …`, with a [personal access token](https://riskregister.com/docs/authentication.md#personal-access-tokens) or an [organisation API key](https://riskregister.com/docs/authentication.md#organisation-api-keys). OAuth, for clients that can't send a header (claude.ai connectors), comes later | | Limits | A request body of 1 MiB plus 64 KiB for the protocol's envelope, so a document at the document save's 1 MiB limit fits; 180 seconds per request | ## Connecting With a personal access token (create one at `/account/personal-access-tokens`, where the setups are ready to copy, with an **Install in VS Code** button). The assistant acts as you: your role, and the registers you can open. Any client that can send a header to a remote MCP server connects the same way: the URL and the `Authorization: Bearer` header. [Connect an AI assistant](https://riskregister.com/docs/connect-assistants.md) has the setup for Claude Code, VS Code, Cursor, Gemini CLI and Codex CLI, where each keeps the token, and what to do if it doesn't connect. claude.ai, Claude Desktop and ChatGPT connect by signing in, which comes with OAuth. Clients connect from a server or a desktop app, not a browser page: a request carrying another origin's `Origin` header is refused with a `403`, and no CORS headers are served (so a browser-based inspector's direct mode can't connect). A missing or unknown token gets a `401` with `WWW-Authenticate: Bearer realm="riskregister"` (plus `error="invalid_token"` for an unknown one), before any tool runs. A suspended organisation, a plan or operator switch that closes the API to the token, and the rate limiters answer as the REST API does (`403`, `429` with `Retry-After`). ## Tools | Group | Tools | Profiles | | --- | --- | --- | | Workspace | `get_workspace_context`: who the token acts as, the plan, its caps and what is left; `get_my_work`: what is waiting for that person across every register they can read; `list_members`: the people in the workspace, or those a register's work can be given to, for the id a request that names someone needs | both | | Registers | `list_registers`, `get_register`, `get_register_document`, `get_changes` (since a revision), `get_review_queue` (what waits for a person), `list_proposals` and `get_proposal` (what a person decided), `get_treatment_plan` (every action across the register, and decisions with nothing open), `get_statement_of_applicability` (the ISO 27001 or 42001 Annex A position the register implies, with the organisation's recorded decisions; it only reads, and decisions are recorded in the app; Team and above), `export_register` (CSV or Markdown), `generate_register_from_context` | both | | Templates | `list_templates`, `get_template_document`, `create_register_from_template` | both | | Saving work | `create_register_draft` (a whole register, from a v5 document, as a draft), `propose_register_changes` (changes to an accepted register, for a person to decide) | both | | Risks | `search_risks` (across registers), `list_risks`, `get_risk`, `create_risk`, `assess_risk`, `treat_risk` | both | | Reviews | `list_risk_reviews`, `get_risk_review`, `request_review`, `complete_review`, `record_review` (a review just done, with no request first), `cancel_review`. Completing or recording a review takes up to five evidence links, http or https, which every read of the review returns | both | | Controls | `list_controls`, `get_control`, `list_control_implementations`, `get_control_implementation`, `review_control_implementation` | both (implementations: Business plan) | | Scopes | `list_scopes`, `get_scope` | both (Business plan) | | Reference | `list_risk_categories` | both | | Webhooks | `list_webhook_endpoints`, `get_webhook_endpoint`, `list_webhook_deliveries` | full (admins, Business plan) | | Audit | `list_audit_log` | full (admins) | **Which tools a token is offered** depends on its access ([Authentication](https://riskregister.com/docs/authentication.md)), which its owner can change at any time: a `read` (Read only) token gets only the reads (`export_register` included); a `propose` (Ask me first) token also gets `create_register_draft`, `create_register_from_template`, `propose_register_changes` and `generate_register_from_context` (whose register is then a draft); a `write` (Make changes) token gets every tool its profile has. `tools/list` lists only those, so an assistant isn't offered calls that would be refused. A client keeps the list it was given, and the endpoint keeps no session to tell it of a change, so after the access changes, reconnect the app, or start a new conversation, so it sees the tools the new level allows. A tool called by name anyway is unknown, and its route would refuse it in any case (`403`, `INSUFFICIENT_SCOPE`). **What the server tells the assistant.** The handshake carries `instructions`, written for the token's access: call `get_workspace_context` first; with a `propose` token, what you save waits for a person to review it; with a `write` token (Make changes), a change the user asks for is made with the tool that makes it, and only what no tool makes (a title, a description, an owner, a control link), or what the user wants looked at first, is proposed; give the user the `reviewUrl` of a draft or proposal; never invent a score, owner or source; never set a risk to `accepted`; and whether a generated register is a draft (`propose`) or live (`write`). Each names only the tools that access is offered. The Claude plugin's skill says the same at length. Every tool states a title and all four annotations. A read is `readOnlyHint` and `idempotentHint`. A write is neither, and a write that changes an existing record is `destructiveHint`, conservatively, even where the audit log keeps the old value. Only `generate_register_from_context` is `openWorldHint`: the description you give it goes to a third-party AI model. The tools don't delete anything, and don't expose every write the REST API has: `tests/regression/mcp-openapi-parity.test.ts` lists each one left out, with the reason. ## How a tool call behaves - **Results.** A read returns `data` as JSON text. A paged list adds `nextCursor` and `hasMore` (`list_risk_categories` is one short list, in one call). A single record adds its `etag` where the route versions it: registers, risks, reviews, controls, implementations and scopes. A `search_risks` result names its register, with its state and its revision at the moment of that page. - **Size.** A result carries at most 60,000 bytes (Claude Code refuses one over 25,000 tokens by default). Past that, a read is a tool error, `PAYLOAD_TOO_LARGE` with subcode `RESULT_TOO_LARGE` and no `status` (no route refused it), which names a `limit` that fits or another way to read the record (a large register's document: its risks a page at a time with `search_risks`). A write is never refused after the fact: its change is made, so its result keeps what names the record (ids, refs) and says the rest is left out. An error that big keeps its status and message, without its details. - **Pages.** For the next page, call again with the **same arguments** plus `cursor: nextCursor`. The cursor keeps a position, not your filters. A sorted `list_risks` is one page, up to `limit`, with no cursor. - **Errors.** A REST error comes back as a tool error (`isError`) the model can read: the `status`, `code`, `subcode`, `message`, `details` and `requestId`, and `retryAfterSeconds` on a `429`. A plan limit says so apart from a permission refusal: subcode `PLAN_LIMIT` (with the limit and the cap), `OPERATOR_LIMIT` (the same, set by an operator: upgrading won't lift it), `FEATURE_NOT_ENABLED` (the plan lacks the feature) or `FEATURE_TURNED_OFF` (an operator turned it off). Arguments that don't fit a tool's schema (a wrong type, a value outside its list, or a field it doesn't know) are refused before any route is called (`Input validation error: …`). A misspelled field is never dropped silently. - **Idempotency.** Every write takes an `idempotencyKey`, sent as `Idempotency-Key`. A retry with the same key and arguments writes once and returns the record as it now stands. `generate_register_from_context`, `create_register_draft`, `create_register_from_template` and `propose_register_changes` require one: their work can outlast a client's timeout and still finish, so retry with the same key to get its result. See [Idempotency](https://riskregister.com/docs/idempotency.md). - **Nothing is adopted.** A draft becomes a register, and a proposal changes one, only when a person reviews it in the app. The draft tools return the draft's review URL. `generate_register_from_context` returns its register's `reviewUrl` too: the register is a draft with a `propose` token, and live at once with `write`. `propose_register_changes` returns the proposal's `reviewUrl`, the register's Proposals tab, where a person sees each change field by field and adopts or declines it; everyone else who can adopt it is emailed that link, at most once a day per register, and everyone who can adopt a proposal still open after a week is reminded. A new draft emails the person it was saved for its link. Each person can turn these off under Drafts and proposals in their notification settings. No tool adopts, accepts or decides, and a document save can't mark a risk accepted. - **Files.** `export_register` returns two parts: what the file is (its filename, content type and size, and whether it is cut short), then its text (CSV or Markdown). A file past the size limit is cut after its last whole row, never refused: the export has already been counted and audited. - **Smaller forms.** A read past the size limit may come back in a smaller form that fits, saying in `omitted` what it leaves out and where to read it. Each read tries its forms most complete first, and is refused only when none fits. A template's read drops its guide, then its risks' rationales, then their descriptions. A register's document drops each risk's description, rationale and sources, then comes as one line a risk, then as a summary (counts by category, status and treatment, and every risk's key and title) that isn't a document and can't be saved back. - **Unknown tools.** Calling a tool the profile doesn't serve (an admin tool on `/mcp`) is a JSON-RPC error, `Tool … not found`, not a tool error. - **What a tool reads.** What its token reads: an owner's or admin's token reads every register, and a member's token the registers they've been added to. Another register's data is a `403` tool error, and `list_registers` shows only the member's registers. An organisation key reads as the person who minted it, with their role at the time of the call. See [What a token reads](https://riskregister.com/docs/authentication.md). - **Concurrency.** `ifMatch` is optional: without it no version check happens. `assess_risk` and `treat_risk` take an `ifMatch`: the `etag` from `get_risk`. `complete_review` and `cancel_review` take one from `get_risk_review`. If the record has changed since, the call fails with a `412` and writes nothing. Unlike the REST error, the tool error leaves out the record's current etag: read the record again. The value goes as `x-rr-if-match`; see [ETag concurrency](https://riskregister.com/docs/etag-concurrency.md). - **Rate limits.** Every MCP request counts against the token's read limit, as every REST request does. A tool call's own route call then counts as it would over REST, so a tool call costs one read more than the REST call. See [Rate limits](https://riskregister.com/docs/rate-limits.md). - **Audit.** A write's audit row names the token, as over REST. Its user agent reads `riskregister-mcp/app ()`. ## When the endpoint is paused riskregister.com can pause the endpoint without touching the web app or the REST API, for an incident or to stop runaway cost. Either way the answer is `503` with code `SERVICE_UNAVAILABLE`, and nothing is done: - **Changes paused** (subcode `MCP_WRITES_PAUSED`): the tool list and every read work as usual; a tool that would change something (a save, a proposal, a risk or review change, a generation, and an export, which is recorded and counted) comes back as a tool error, before anything is written. Tell the person the change wasn't made; they can make it in the app, or ask again later. - **Endpoint paused** (subcode `MCP_PAUSED`): every request is refused before its token is checked, so a client can't connect. A pause lasts until it is lifted: don't retry in a loop. Drafts and proposals already saved stay, for people to review in the app. --- Source: https://riskregister.com/docs/connect-assistants.md # Connect an AI assistant riskregister.com serves an MCP endpoint at `https://mcp.riskregister.com/mcp`. MCP, the [Model Context Protocol](https://modelcontextprotocol.io), is the standard way AI assistants call outside tools. An assistant connects by signing in to riskregister.com (claude.ai, Claude Desktop, ChatGPT) or with a personal access token (the other clients here), then reads and works on your registers with the same checks as the REST API. This page gives the setup for each client. For the tools themselves, and how a tool call behaves, see [MCP endpoint](https://riskregister.com/docs/mcp.md). Any client that connects to a remote MCP server over Streamable HTTP and can send a header connects the same way: the URL, and an `Authorization: Bearer` header carrying the token. The endpoint keeps no sessions, so a client that supports only the older SSE transport can't connect. ## What you need A personal access token. Create one at `https://riskregister.com/account/personal-access-tokens` (not on `mcp.riskregister.com`, which serves only the endpoint). It is shown once, so copy it straight away. Choose how long it lasts (30 days, 90 days, a year, or no expiry); until then, anyone who has it can do whatever its access allows. When it expires the assistant's calls are refused with the date it expired: make a new one. Choose its access: **Read only**, **Ask me first** (the registers it saves are drafts and its changes are proposals, for a person to adopt, and it can't change an accepted register directly; a save can still add categories and controls to the workspace's library, a new draft uses one of your plan's registers, and a register it has us generate, a draft too, uses your plan's AI allowance) or **Make changes** (everything your role allows, made at once; the page preselects it). You can change a token's access at any time on the same page, and its next request uses the new level; then reconnect the app, or start a new conversation, so it sees the tools the new level allows (an app keeps the list of tools it got when it connected). Make one for each assistant, named after it so the audit log tells them apart, and revoke it on the same page when you stop using it or if it may have leaked. Personal access tokens work on every plan, including Free. ## What the assistant can do The assistant acts as you: your role, and the registers you can open. Not everything it does waits for review: - **Saved for review** (Ask me first, or Make changes). A register it saves as a document, or starts from a template, lands as a draft. With Ask me first, so does one it has riskregister.com generate from a description (`generate_register_from_context`). Changes it sends as a document to an accepted register land as a proposal. Neither counts until someone who may adopt it does so in the app, and that can be you. - **Made at once** (Make changes only). It can add a risk, re-assess it, change its treatment or close it, manage a risk's review and record a control's review, as you could in the app. `generate_register_from_context` creates a working register, not a draft, with risks and scores from an AI model. On an accepted register these count straight away, in reports, reminders and webhooks. - **Never.** It can't delete anything, adopt a draft, decide a proposal or formally accept a risk. Every change it makes is in the audit log, with the token that made it, for your organisation's owners and admins to read. [Authentication](https://riskregister.com/docs/authentication.md#personal-access-tokens) says what a token reads. What the assistant reads goes to it, and so to the AI provider you chose: check what that provider does with it, including whether it may train on it. `generate_register_from_context` also sends your description to Anthropic, riskregister.com's AI provider, and counts against your plan's AI allowance. If you are an assistant reading this page: change a register directly only when the person asks for that exact change, and say it is done, not proposed. Never ask for the token. ## Where the token is kept The token page has each client's main setup below ready to copy, and shows the Claude Code command and the Cursor config with a new token filled in, once. Claude Code's environment-variable form, the project `.mcp.json` and Cursor's `${env:…}` header are only on this page. Run the command or edit the file yourself: a token pasted into an assistant's chat stays in the transcript and goes to its provider. | Client | Settings | What they hold | | --- | --- | --- | | Claude Code | `~/.claude.json` | The token in plain text, or a reference to `RISKREGISTER_TOKEN` | | VS Code | Your user profile, or `.vscode/mcp.json` | No token: VS Code asks for it and keeps it in its secret storage | | Cursor | `~/.cursor/mcp.json` | The token, in plain text | | Gemini CLI | `~/.gemini/settings.json` | A reference to `RISKREGISTER_TOKEN` | | Codex CLI | `~/.codex/config.toml` | The variable's name, `RISKREGISTER_TOKEN` | Where a client reads `RISKREGISTER_TOKEN`, set the variable without writing the token into a file. For one session: ```bash export RISKREGISTER_TOKEN="rr_pat_live_…" ``` To keep it, save the token in your system keychain and read it from there where your shell starts. On macOS, save it once (`security add-generic-password -a "$USER" -s riskregister -w` asks for it), then put this line in your shell profile, such as `~/.zshrc`: ```bash export RISKREGISTER_TOKEN="$(security find-generic-password -s riskregister -w)" ``` The profile then holds the command, not the token. Start the client from a new shell. ## Claude Code ```bash claude mcp add --scope user --transport http riskregister https://mcp.riskregister.com/mcp --header "Authorization: Bearer rr_pat_live_…" ``` `--scope user` adds the server for you in every project. This is the command the token page shows. It leaves the token in your shell history (on a shared machine, delete that line from your history file, `~/.zsh_history` in zsh), and Claude Code writes it in plain text to `~/.claude.json`, where `claude mcp get riskregister` prints it in full. The better default keeps it out of both, with a reference to the variable: ```bash claude mcp add --scope user --transport http riskregister https://mcp.riskregister.com/mcp --header 'Authorization: Bearer ${RISKREGISTER_TOKEN}' ``` The single quotes stop your shell expanding the variable, so `~/.claude.json` holds `${RISKREGISTER_TOKEN}` and Claude Code fills in the value when it connects. If the variable isn't set where Claude Code starts, the endpoint answers `401`. Keep `--header` after the URL: placed before the name, it takes the name and the URL as more headers, and the command fails. For a project your team shares, the same reference can go in the project's `.mcp.json`, which holds no token and so can be committed. Each person sets their own `RISKREGISTER_TOKEN`, and Claude Code asks each of them to approve the project's server. ```json { "mcpServers": { "riskregister": { "type": "http", "url": "https://mcp.riskregister.com/mcp", "headers": { "Authorization": "Bearer ${RISKREGISTER_TOKEN}" } } } } ``` riskregister.com's plugin for Claude Code, which asks for the token when you install or enable it in a Claude Code session and keeps it out of your settings files, isn't published yet. ## VS Code On the token page, **Install in VS Code** (or **Install in VS Code Insiders**) opens the server in VS Code, ready to install. Nothing is added until you confirm there. The server goes in your user profile, so it is there in every workspace. The link carries no token: VS Code asks for it the first time the server starts, hides what you type, and keeps it in its secret storage. Or add it to a workspace's `.vscode/mcp.json` yourself: ```json { "servers": { "riskregister": { "type": "http", "url": "https://mcp.riskregister.com/mcp", "headers": { "Authorization": "Bearer ${input:riskregister-token}" } } }, "inputs": [{ "type": "promptString", "id": "riskregister-token", "description": "riskregister.com personal access token", "password": true }] } ``` The token stays out of the file. Leave `"password": true` in place: it hides what you type, and VS Code keeps the token in its secret storage only while `password` is `true`. If the file already lists servers, add `riskregister` to its `servers` and the input to its `inputs`. ## Cursor In `~/.cursor/mcp.json`, in your home folder, which Cursor reads for you in every project: ```json { "mcpServers": { "riskregister": { "url": "https://mcp.riskregister.com/mcp", "headers": { "Authorization": "Bearer rr_pat_live_…" } } } } ``` Use this file, not a project's `.cursor/mcp.json`: that one is often committed, and would carry your token. The token sits in the file in plain text. If the file already lists servers, add `riskregister` to its `mcpServers`. Cursor can take the token from an environment variable instead, with `"Authorization": "Bearer ${env:RISKREGISTER_TOKEN}"`, but only if Cursor itself can see the variable. Opened from the Dock or a desktop launcher, it may not see what your shell profile sets, and the endpoint then answers `401`. Restart Cursor after you set the variable. This is also why the token page has no install button for Cursor: Cursor's install links can't ask for a token. ## Gemini CLI ```bash gemini mcp add --transport http -s user --header 'Authorization: Bearer ${RISKREGISTER_TOKEN}' riskregister https://mcp.riskregister.com/mcp ``` `-s user` adds the server for you in every project, in `~/.gemini/settings.json`. Without it, Gemini CLI writes to the current project's `.gemini/settings.json`. The single quotes keep the reference in the file, and Gemini CLI fills in `RISKREGISTER_TOKEN` when it loads its settings, so set the variable in the shell you start `gemini` from. Run any later `gemini mcp add` or `gemini mcp remove` in the same scope with the variable unset (`env -u RISKREGISTER_TOKEN gemini mcp add …`): those commands write the server list back with variables filled in, so they can put the token's value in the file. riskregister.com's extension for Gemini CLI, which asks for the token when you install it, isn't published yet. ## Codex CLI In `~/.codex/config.toml`: ```toml [mcp_servers.riskregister] url = "https://mcp.riskregister.com/mcp" bearer_token_env_var = "RISKREGISTER_TOKEN" ``` Codex sends the value of `RISKREGISTER_TOKEN` as the bearer, so the file holds only the variable's name. The Codex app and IDE extension read the same file, but may not see variables set in your shell. For them, Codex's documentation says to put the variable in `~/.codex/.env`, a plain-text file, and restart. ## claude.ai, Claude Desktop and ChatGPT These connect by signing in to riskregister.com (OAuth), with no token to copy. **claude.ai.** Add a custom connector with the URL `https://mcp.riskregister.com/mcp` ([Claude's guide](https://claude.com/docs/connectors/custom/remote-mcp)) and choose Connect. riskregister.com asks you to sign in, then to allow Claude to act on your account; if you have no workspace yet, you set one up first and come back to allow it. The connector is then on your Claude account, so Claude Desktop and Claude's mobile apps have it too. Turn it on in the conversation that should use it, and start a new conversation if one started before you connected doesn't see its tools. **ChatGPT.** Open **Plugins**, choose **Add** > **Add custom MCP server**, and enter the same URL. Keep **OAuth** as the authentication (ChatGPT finds riskregister.com's sign-in by itself), accept the risk notice and choose **Create as a plugin**, then **Connect**: sign in and allow it the same way. In a new chat, type `@` and pick riskregister.com. Developer mode isn't needed any more, but some ChatGPT plans and workspaces don't offer custom servers, or need an admin to allow them. The consent screen lists everything the sign-in asks for, including "private metadata" and "public metadata". riskregister.com keeps nothing in either: the names are the sign-in provider's, and the app reads neither. Sign in with an account that belongs to your workspace: an account without one, such as a riskregister.com operator account, can't connect an app. A newly connected app starts at **Make changes**. Choose what each app may do (Read only, Ask me first or Make changes), or disconnect it, at `https://riskregister.com/account/connected-apps`. Its next request uses the new level. Claude Desktop's Code tab runs Claude Code, so the Claude Code setup applies there. Without a connection you can still bring in a register the assistant writes. Ask it for a riskregister.com/v5 document, or for a table with the columns in the register document (Key, Title, Category, Owner, the four scores and Treatment), then paste its answer at riskregister.com/import. The page shows what will be saved before you sign in, then saves it as a draft for you to review and adopt. claude.ai's custom connectors can also send a request header, for a limited set of organisations, as a beta. Sign in instead, and don't enter a personal access token there: on claude.ai's Team and Enterprise plans, the header a claude.ai Owner enters is sent for everyone who uses the connector, so they would all act as the person whose token it is. ## Check that it works Start a new session in the client, then ask the assistant to call `get_workspace_context`. It answers with who the token acts as, the workspace and its plan. In Claude Code, `/mcp` or `claude mcp list` shows whether `riskregister` connected. ## If it doesn't connect | What you see | What it means | What to do | | --- | --- | --- | | `401` with `WWW-Authenticate: Bearer realm="riskregister"` | No token arrived, or the endpoint doesn't accept the one that did (then with `error="invalid_token"`): mistyped, revoked, or a variable the client didn't fill in | Check the header reads `Bearer ` and then the whole token, and that the variable is set where the client starts. Create a new token if the old one was revoked | | `403` on connecting | The token is valid, but the API is closed to it: your organisation is suspended (subcode `ORG_SUSPENDED`), or riskregister.com has turned off personal access tokens or API access for it (the message says which; these have no subcode) | Ask your organisation's admin | | `403` from a browser page | The request came from a web page on another site. The endpoint refuses those and serves no CORS headers, so a browser tool such as the MCP Inspector can't connect in its direct mode | Connect from a desktop app or a command-line client, or use the Inspector's proxy mode | | `429` with `Retry-After` | A rate limit ([Rate limits](https://riskregister.com/docs/rate-limits.md)) | Wait that many seconds | | `503`, subcode `MCP_PAUSED` | riskregister.com has paused the endpoint | Try again later, not in a loop | | `503`, subcode `MCP_WRITES_PAUSED`, on a tool call | Reads work, but riskregister.com has paused changes made through assistants, exports included | Nothing was changed. Make the change in the app, or ask again later | Once connected: - **A register you expected isn't there.** Tools act with your role. An owner's or admin's token reads every register; a member's reads the registers they have been added to. `list_registers` shows only those, and another register's data is a `403` tool error. Ask an admin to add you to the register. - **The webhook and audit-log tools aren't listed.** They are served only at `https://mcp.riskregister.com/mcp/full` (every tool), and only an organisation owner's or admin's token can use them; the webhook tools also need the Business plan. Anyone else's calls come back as a `403` tool error. To use them, connect to that URL instead of `/mcp`. ## What was checked Signing in was checked on 27 October 2026 from claude.ai and from ChatGPT, by connecting each to production and having it read the workspace (`get_workspace_context`, then the registers). Each token setup on this page was checked on 7 October 2026 against the client's own documentation and, where that is silent, against its published source code (VS Code's install link and token storage; what Gemini CLI writes for the header), a run of the client (Claude Code's `--header` placement and what `claude mcp get` prints), or the vendor's staff answers on its forum (Cursor and environment variables). Clients change their formats: if a step here no longer matches your client, its documentation is the better guide. --- Source: https://riskregister.com/docs/drafts-and-proposals.md # Drafts and proposals An assistant, or any API caller, can save a new register as a **draft**, or send changes to an accepted register as a **proposal**. Neither takes effect until a person adopts it in the app. Not every write waits for that: some MCP tools, and more REST routes, change a register at once ([see below](#what-an-assistant-can-and-cant-do)), and the categories and controls a draft save creates join the workspace's library straight away. | You are saving | It lands as | Saved with | A person decides it | | --- | --- | --- | --- | | A new register, as a v5 document or from a template | a **draft** | `POST /api/v1/registers/from-document` (MCP: `create_register_draft`), or `POST /api/v1/registers/from-template` (`create_register_from_template`) | on the register's page: **Adopt draft** or **Discard draft** | | A new register an AI model writes from a description, with a `propose` token or app | a **draft** (with `write`, an accepted register: see [below](#what-an-assistant-can-and-cant-do)) | `POST /api/v1/registers/from-context` (`generate_register_from_context`) | on the register's page: **Adopt draft** or **Discard draft** | | Changes to an accepted register, sent as a v5 document | a **proposal** | `POST /api/v1/registers/{id}/proposals` (`propose_register_changes`) | on the register's **Proposals** tab: **Adopt** or **Decline** | Send requests to `https://riskregister.com` with `Authorization: Bearer ` and JSON bodies with `Content-Type: application/json` ([Getting started](https://riskregister.com/docs/getting-started.md)). Every register has a `state`, `draft` or `accepted`, on its API reads and in its document. For a register, `accepted` means adopted: it is unrelated to a risk's `accepted` status (a formal acceptance) and to the `accept` treatment. Each save returns a `reviewUrl`: hand it to the person who will decide. That can be the token's owner, who is a new draft's register admin; a proposer can adopt their own proposal too. What is checked is that a signed-in person decides in the app, not that a second person reviews. ## Drafts ### Saving a draft The body of `POST /api/v1/registers/from-document` is a `riskregister.com/v5` document ([the format](https://riskregister.com/docs/register-document.md)), at most 200 risks and 1 MiB. Save a larger register in several parts to the same `register.key` (see [Saving to a draft again](#saving-to-a-draft-again)), or import the file in the app: the API has no import route. - Every risk needs inherent likelihood and impact, a category and a treatment. A row missing any of them (which the v5 format allows in a "draft" document) is rejected with its reasons, and the rest are saved. A new risk with no residual score takes its inherent score as its residual, crediting no control until one is shown to work, as a template start and generation do; the response's `warnings` says how many. A residual stated as `unassessed` is still rejected, and an existing risk a row leaves residual out of keeps its own. - A save can't mark a risk `accepted`. A formal acceptance, with an approver and an expiry, is recorded in the app or through `POST /api/v1/risks/{id}/accept`; no MCP tool records one. - If every row is rejected, nothing is saved: a `400` `VALIDATION_ERROR` with the reasons in `details.rejected`. - A draft is a register, so it counts toward the plan's register limit (three on Free). At the limit, a save that would make a new draft is a `403` with subcode `PLAN_LIMIT` (`OPERATOR_LIMIT` if an operator set it); a save into an existing draft isn't refused. - The token's owner (for an organisation key, its creator) becomes the new draft's register admin. The response is `201` for a new draft, and `200` for a save into an existing draft or a replay: | Field | What it holds | | --- | --- | | `register` | The register, with `state: "draft"` (on a replay, as it is now: `accepted` if adopted since) | | `reviewUrl` | The register's page in the app, where it is adopted or discarded | | `mode` | `create` (a new draft) or `merge` (saved into the draft `register.key` names) | | `risks` | Each saved row: `key`, `id`, `ref` and `outcome` (`created`, `updated` or `unchanged`) | | `rejected` | Each rejected row: `key` and `reasons` | | `warnings` | Anything the save did not store | | `controls`, `categoriesCreated` | Control links (`linked`, `created`, `notLinked`) and the categories the save created | | `replayed` | `true` when this answers an earlier request with the same `Idempotency-Key` | `POST /api/v1/registers/from-template` takes `{ "template": "", "name": "…", "key": "…" }` (`name` and `key` optional) and starts a draft from the template's risks and suggested controls. Without a `key`, no later document save can reach the draft. ### Saving to a draft again Give the register a `key` in the document (`register.key`) on the first save. A later save with the same key merges into that draft, risk by risk: a changed row is updated, a new key is added, a row left out is left alone, and an unchanged row writes nothing. A row that moves residual above inherent needs a `rationale` (1 to 2,000 characters). Saving into a draft needs write permission on it, and register admin when the save changes the draft's own fields (its name, description, type, review cadence or custom field values). Without a key, every save makes a new draft that counts toward the register limit, and that draft can't take a document save later: add to it with `create_risk` (`POST /api/v1/registers/{id}/risks`). A draft has no revision check. A save overwrites the rows it names, a person's edits to them included, and puts back a keyed row a person deleted. Read the draft back first (`GET /api/v1/registers/{id}/document`), keep those edits, leave out deleted rows, and keep each row's `id`: it names a risk someone added in the app, and without it the row is saved again as a new risk. | Status | Subcode | When | | --- | --- | --- | | `409` | `API_V1_REGISTER_KEY_TAKEN` | The key names a register that has been adopted. A save never overwrites an accepted register: propose changes instead | | `409` | `API_V1_REGISTER_NOT_ACTIVE` | The draft the key names has been archived or closed | | `409` | `API_V1_REGISTER_MATRIX_IMMUTABLE` | The document names a different scoring matrix from the draft's | | `409` | `API_V1_IDEMPOTENCY_BODY_MISMATCH` | The same `Idempotency-Key` with a different document | | `409` | `API_V1_DRAFT_DISCARDED` | A retry (same `Idempotency-Key`) of a save whose draft a person has since discarded. To save again, use a new `Idempotency-Key`; the same `register.key` makes a new draft | The `Idempotency-Key` header is optional on the REST route. The MCP tools take it as the `idempotencyKey` argument, and require it. A retry with the same key and document returns the first save's result and writes nothing. See [Idempotency](https://riskregister.com/docs/idempotency.md). ### What a draft is, until someone adopts it - **Visible and labelled.** A draft shows in the register list, search and its own pages, marked as a draft, and in exports and API reads with `state: "draft"`. - **Not counted as accepted work.** It is left out of the organisation-wide risk totals (the posture rollup), each person's My Work list, review reminders, the weekly owner digest and board reports. It can't be cloned until it is adopted. - **Held from webhooks.** No event about it or its risks is sent while it is a draft. Adoption sends `register.created` and `risk.created` once, for what it holds then. - **Still writable.** Direct writes (for example `create_risk`) land in the draft, which a person still adopts. ### Adopting or discarding a draft Both happen in the app, on the register's page, and both need register admin permission. Organisation owners and admins have it unless they hold another role on that register. - **Adopt draft** makes it an accepted register. In one step it confirms every risk still marked as needing confirmation (as a save, a template start or a generation marks its risks), records a `draft_adopted` audit row, sends `register.created` and one `risk.created` per risk to webhooks, and emails each risk owner other than the adopter that they own their risks, and each reviewer asked while it was a draft that a review waits (unless they turned off **Assigned to me**; at most once a day per register). Adopting a register that is already accepted changes nothing. - **Discard draft** permanently deletes the draft and its risks, and records the deletion in the audit log. Categories and controls its saves created stay in the workspace's library. Discarding frees the draft's place under the register limit; archiving doesn't. An adopted register can't be discarded this way. ## Revisions Every register has a `revision`, a whole number that goes up once for each transaction that changes its content: the register's own fields, its risks, their control links and their custom field values. Settings such as status and appetite, and organisation-level objects such as controls and categories, don't move it. - `GET /api/v1/registers/{id}` returns `revision`, the document carries `register.revision`, and each result of `GET /api/v1/risks` (MCP: `search_risks`) names its register's revision. - `GET /api/v1/registers/{id}/changes?since=` lists what changed after a revision you read (MCP: `get_changes`). - Each adopted proposal keeps a snapshot of the register at the revision it produced: `GET /api/v1/registers/{id}/revisions`, `GET …/revisions/{revision}`, and `GET …/document?revision=N`, which also renders the current revision, kept or not. Any other revision, including one a direct change produced, is a `404` with subcode `API_V1_REVISION_NOT_RETAINED` (`…/changes` still says what changed at it); one ahead of the register's is a `400`. Both carry `details.currentRevision`. ## Proposals ### Making a proposal `POST /api/v1/registers/{id}/proposals` takes a JSON body and a required `Idempotency-Key` header. The register must be accepted and active, and you need write permission on it, or register admin when the proposal changes the register's own fields (a `403` otherwise). | Field | Notes | | --- | --- | | `expectedRevision` | Required. The register's revision when you read what you are changing. It goes in the body only: an `If-Match` or `x-rr-if-match` header is refused | | `document` | The changes, as a v5 document in which every field but `format` (`"riskregister.com/v5"`) and each risk row's `key` is optional, and a missing field means unchanged. At least one risk row or a `register` block; at most 200 rows and 1 MiB | | `reason` | Required, 1 to 2,000 characters. Why, for the person deciding | | `supersedes` | Optional. The id of one of your own open proposals on this register that this one replaces | How the rows are read: - A row with an `id` (the risk's UUID) changes that risk, as does a row whose `key` is a key a document save gave a risk. Only the fields the row carries change. - A risk made in the app has no key of its own, so a document read gives its ref (such as `RISK-0042`) as its `key`: keep the row's `id`. A key equal to an existing risk's ref or id, with no `id`, is refused as ambiguous. - A row with a new key and no `id` adds a risk, which needs a title, a category, an inherent score and a treatment. With no residual score it is proposed with its inherent score as its residual, and the proposal's `warnings` says so. - A risk is closed with `status: "closed"`. Nothing is deleted, and control links are only added. - A proposal can't set `status: "accepted"`, or change an accepted risk's scores, treatment or status. - Residual above inherent needs a `rationale` on that row, of 1 to 2,000 characters. - A proposal that would change nothing is refused. | Status | Code and subcode | Meaning | | --- | --- | --- | | `201`, `200` | | Stored, or a replay of the same key and body | | `400` | `VALIDATION_ERROR` | No `Idempotency-Key`; an `If-Match` header; or `expectedRevision` ahead of the register (`details.currentRevision`) | | `409` | `CONFLICT`, `API_V1_PROPOSAL_DRAFT_REGISTER` | The register is a draft, which takes no proposal: save to it under its `register.key` if it has one, or use direct writes | | `409` | `CONFLICT`, `API_V1_REGISTER_NOT_ACTIVE` | The register is archived or closed | | `409` | `CONFLICT`, `API_V1_PROPOSAL_QUEUE_FULL` | The register already has 50 open proposals | | `409` | `CONFLICT`, `API_V1_PROPOSAL_SUPERSEDES_UNKNOWN`, `…_NOT_OWN`, `…_CLOSED` | `supersedes` names no proposal here, someone else's, or one no longer open | | `409` | `CONFLICT`, `API_V1_IDEMPOTENCY_BODY_MISMATCH` | The same key with a different body, or sent to another register | | `412` | `PRECONDITION_FAILED`, `API_V1_PROPOSAL_STALE` | Something it changes has changed since `expectedRevision` | | `422` | `UNPROCESSABLE`, `API_V1_PROPOSAL_REJECTED` | Rows refused, listed in `details.rejected` with `details.warnings`. Nothing is stored | | `428` | `PRECONDITION_REQUIRED` | `expectedRevision` is missing | Staleness is judged per risk, not per register. A proposal is refused only when a risk it updates changed in any way after `expectedRevision`, when it changes the register's own fields and those changed since, or when a risk it names (by id, or by a key that risk had) was deleted since; an edit to an unrelated risk doesn't count. The `412` carries `details.currentRevision` and `details.changes`, each with `entity` (`register` or `risk`), `id`, `revision` and `deleted`. Read what changed, rebuild the proposal on it, and propose again with the new revision and a new `Idempotency-Key`. Don't resend the same proposal with only the revision raised. ### What a proposal holds A proposal is stored as made, and its content can't change afterwards. The create response, and `GET /api/v1/registers/{id}/proposals/{proposalId}`, return: - `id`, `registerId`, `baseRevision` (the `expectedRevision` it was made against), `reason` and `warnings`; - `state`: `open`, `adopted`, `declined`, `superseded` or `expired`; - `changes`: each change with its before and after, as resolved when it was made, and `changeHash`, a canonical hash of them; - `createdBy` (`userId`, `tokenId`), `createdAt` and `expiresAt`; - `supersededBy`, `decidedBy`, `decidedAt`, `decisionNote` and `adoptedRevision`, once it is closed. The create response adds `reviewUrl`, the register's Proposals tab; the single read adds `document`, as proposed. `GET /api/v1/registers/{id}/proposals` lists summaries without `changes` or `document`, newest first and [cursor-paginated](https://riskregister.com/docs/pagination.md); `?state=` takes `open` (the default), `adopted`, `declined`, `superseded`, `expired` or `all`. `GET /api/v1/registers/{id}/review-queue` (MCP: `get_review_queue`) also lists the open ones, the soonest to expire first. A proposal expires 14 days after it is made unless someone decides it first. An hourly job marks it `expired`, and reads report one past its `expiresAt` as expired before then. A proposal made with `supersedes` marks the one it replaces `superseded`, pointing at the new one; the replaced one doesn't count toward the cap of 50. ### Adopting or declining, in the app The Proposals tab (`/{org}/{register}/proposals`, the `reviewUrl`) lists open proposals, oldest first. Each shows who made it and through which token, the reason, when it expires, and what it would change now, field by field, resolved as adoption would resolve it. If something stops it being adopted as it stands, such as an edit to something it changes since the assistant read the register, the tab says so and offers only Decline. - **Adopt** sends the change hash of what the page showed. Adoption resolves the proposal again and writes nothing if it has gone stale, if a row can no longer be applied, or if what it would change now differs from what the page showed (a category renamed since, for example); the page then reloads to show the proposal as it stands. A proposal that has gone stale can't be adopted at all: a new one is needed. - Adopted changes are written as the same edit in the app would be, with their audit rows and score history; a category or control the proposal names that doesn't exist yet is created in the workspace's library. The register's revision goes up, a snapshot is kept, webhooks are sent, and the proposal records its `adoptedRevision`. - **Decline** takes an optional note of up to 2,000 characters, kept as `decisionNote`. - Both need register write permission. A proposal that changes the register's own fields needs register admin as well: for adopting, judged on what it would change now; for declining, on what it changed when it was made. ## Who is emailed, and when | Email | Sent when | Sent to | | --- | --- | --- | | A draft is waiting | A new draft is saved through a token (a document save, a template start, or a generation at `propose`) | The person it was saved for: the token's owner, or the organisation key's creator. Once per draft: a save into an existing draft, or a replay, sends nothing | | A proposal is waiting | A proposal is made | Everyone who can adopt it: register write, or register admin when it changes the register's own fields. Not the person whose own personal access token made it; an organisation key's creator is told. At most one email per register per person per UTC day | | A proposal is still waiting | Once per proposal, about a week on: a daily run at 08:30 UTC picks up the open proposals made on the UTC day eight days earlier | Everyone who can adopt at least one of them, the proposer too if they can. One email per register per person that day | Each person can turn all three off under **Drafts and proposals** in their notification settings (`/account/notifications`). An email is dropped unsent if, when it goes, the draft or proposal no longer waits or the person can no longer decide it. ## What an assistant can and can't do These decisions are made only in the app, by a signed-in person. No API route or MCP tool makes them, whatever the token's role, and an assistant must not make them for the person, through a browser or in any other way: - adopt or discard a draft; - adopt or decline a proposal. Some MCP tools write at once, as the token's owner, with nobody's review, and only with **Make changes** access: an **Ask me first** token gets the draft save, the template start, the proposal and a generation that lands as a draft, and none of these ([Authentication](https://riskregister.com/docs/authentication.md)). `create_risk`, `assess_risk`, `treat_risk` (which can close a risk), `request_review`, `complete_review` and `cancel_review` act on a register's risks; `review_control_implementation` records a control implementation's review (an implementation belongs to the workspace, not a register). In an accepted register a risk or review change is live at once: webhooks are sent, a new owner it names and a reviewer it asks are emailed as in the app (each at most once a day per register; what comes later that day waits in their My Work), and no snapshot is kept. In a draft, the change lands in the draft. `generate_register_from_context` makes a new register whose risks and scores an AI model writes. What it makes follows the token's access: with **Make changes** the register is accepted at once, and with **Ask me first** it is a draft, waiting for a person to adopt it like any other (its `register.state` says which, and its `reviewUrl` is the register's page). Either way your description is sent to a third-party AI model, and the call uses the plan's AI allowance and one of its registers. Use these tools only when the person asks for that change. Every change is in the audit log with the token that made it. The REST API has more direct writes than the tools; see [the MCP guide](https://riskregister.com/docs/mcp.md) for the tool list and how a tool call behaves. When you report back to the person: a draft is saved, not live; a proposal is proposed, not made; a direct change is made, and in an accepted register it is live at once. Give them the `reviewUrl` for a draft, a proposal or a generated register. --- Source: https://riskregister.com/docs/reviewing-a-draft.md # Reviewing a register your assistant wrote When your AI assistant (in Claude Code, Codex, Cursor or another tool you've connected) saves a whole register it wrote to riskregister.com, or starts one from one of our templates, it lands as a **draft**. So does a register it has riskregister.com generate from a description, when its access is **Ask me first**. You check and correct it before it joins your organisation's register of record. This page covers what to check, how to fix it, and what to ask your assistant. ## What a draft is, and what adopting it means A draft shows a **Draft** marker in your register list and in search, and a **Draft** banner on its page. Until someone adopts it, it stays out of posture, My work, reminders, digests and board reports, and no webhooks are sent about it (your audit log, and any SIEM stream of it, still records it). You can edit it freely. It counts towards your plan's register limit (three on Free) from the moment it's saved; archiving it doesn't free the place, discarding it does. Within your organisation, only you, its owners and admins, and people added with **Members…** can open a draft. Add a colleague there if you want them to check it. A register admin adopts it with **Adopt draft…** on the banner. The person whose access token your assistant used is one, as are organisation owners and admins unless they hold another role on that register. Adopting can't be undone: the risks count in posture and board reports, risk owners other than you are emailed (unless they've turned that off), and webhooks are sent. Adopting checks nothing. Risks with no owner, scores nobody has looked at and controls nobody has confirmed are adopted as they are. The register's **Awaiting review** label stays until you adopt: editing or assessing a risk doesn't mark it checked, and adopting confirms every risk at once. Keep your own note of what you've checked. Not everything an assistant writes waits for you. With **Make changes** access, a register it has riskregister.com generate isn't a draft: it counts in posture, reminders and board reports, and webhooks are sent, from the start, with every risk owned by you. Its page offers **Accept all**, which, like adopting, checks nothing: it only clears the **Awaiting review** label, and formally accepts no risk. Some tools, such as adding a single risk, also change a register straight away, and also need **Make changes** access. With **Ask me first** ([Connect an AI assistant](https://riskregister.com/docs/connect-assistants.md)), the registers your assistant saves or has us generate arrive as drafts and its changes as proposals, so they wait for you. A generated register's risks and scores are written by an AI model, not worked out by your assistant with you: check its scores with particular care. A save still adds any new categories and controls to your organisation at once, and a new draft takes one of your plan's registers (see below). The checks below still apply. ## A short checklist Before you adopt, you should be able to say yes to each: 1. Every risk is one your organisation actually faces, and none you expected is missing. 2. You can explain every score, and every residual below inherent has a working control behind it. 3. No two risks describe the same thing. 4. You know which controls are new, and whether each is in place or only planned. 5. Every risk has a named owner (not **Unresolved** or unassigned) who can open the register and has agreed to own it. 6. Every new category is one you want to keep. 7. You know which treatments are still **Not yet decided**. ## Risks that don't belong Look for risks that are generic ("cyber attack", with no system named), that assume something untrue about you (a site you don't have, a regulator you don't answer to), or that restate a control as a risk. The app doesn't check a risk's content against your organisation. To correct a risk, open it and use **Edit**. To remove one, use **Delete risk…** in the ··· menu next to **Edit**: it permanently removes the risk and its control links. ## Scores you can't support The save refuses a risk without scores, so every risk in a draft has one, and a guess looks exactly like a considered judgement. A refused risk isn't in the draft at all, and the draft's page doesn't say so: check the number of risks matches what your assistant says it wrote. A new draft also keeps none of your assistant's rationale or sources. If the draft started from a template, its risks and scores are the template's examples, not an assessment of your organisation: check each one as if nobody had scored it. When your assistant starts a draft straight from a template, the email about the draft names the template and the register's **Activity** tab shows it was created from one. If the assistant read a template, adapted it and saved the result, neither says so, so ask your assistant where the risks came from. Could you defend each score to your board? Likelihood and impact should describe the same scenario: an everyday frequency paired with the worst possible impact inflates the score. Check too that the draft uses the same scale as your other registers (1 to 5, or 1 to 3): a register's scale can't be changed later. To rescore, use **Assess** on the risk's page. In **Assess risk**, enter all four scores and a **Rationale** of at least 10 characters, which is recorded in the audit trail. **Edit** changes scores too, but asks for a reason only when a change leaves residual above inherent, so prefer **Assess**. Look hard at residual scores: - **Residual below inherent** claims that controls reduce the risk. The risk's page shows that reduction, marked "with controls", whenever any control is linked, whether or not it is in place. If you can't point to a control that works, set residual equal to inherent, as the app does for registers it generates itself. - **Residual above inherent** is unusual, and a new draft isn't checked for it, so it can arrive with no explanation. Correct it, or give the reason when you assess it. The treatment your assistant suggested shows as **Not yet decided** until someone uses **Treat** to record a decision. Never keep a score you can't explain. ## Duplicates The app doesn't look for duplicate risks in a draft and can't merge two risks. Its only check is that one document doesn't name the same risk twice. You find duplicates by reading. Use **Search risks…** and the category filter to compare similar risks. Watch for the same cause and consequence in different words, and a broad risk listed beside the narrower ones it contains. Keep the better one, use **Edit** to bring across anything worth keeping, link its controls to the one you keep with **Link a control** in **Linked controls**, then delete the other. Saving again makes duplicates too. Two drafts of one register usually mean your assistant saved twice without updating the first: keep one, discard the other. A risk someone added in the app can appear twice after another save: delete the copy. Controls and categories can repeat too (see below). ## Controls The save links a draft's control to one you already have only when your assistant gives that control's internal id and says the document comes from your organisation, or names a control from our **Packs** that you've already added. When your assistant saves into the same draft again, it reuses any control an earlier save of that draft created or linked, as long as the document still uses the same key for it and nobody has deleted it. Otherwise a control named only by its reference, such as CTRL-0012, or by its title, becomes a new control, even if it's named exactly like one of yours. The save tells your assistant, not you, how many it linked and created. New controls join your organisation's controls straight away, not on adoption, and stay if you discard the draft. Nothing on screen lists the controls a save created: open each from **Linked controls** on its risk's page. A control the save created has a **Created** date of the day it was saved, no owner, and under **Covered risks** only this draft's risks, marked **Draft**; a control you already had shows the date it was first added. To remove an invented one: if it duplicates one of your controls, first link yours to the risk with **Link a control**. Then **Unlink** the new one from each risk in **Linked controls**, and an organisation owner or admin deletes it with **Delete control…** in the ··· menu on the control's page. A linked control can't be deleted. If you're discarding the draft anyway, discard first: its risks and their links go with it, and the new controls can then be deleted. ### Planned versus implemented A control on paper is not a control in place. A link records that a control is meant to treat the risk; it says nothing about whether it works, and it doesn't change the score. The register format your assistant saves has no field for whether a control is implemented. On the Business plan and above, record what is in place on the control's page, under **Implementations**, with **Add implementation**. You need a scope first (a site, system, process or supplier): add one under **Scopes** in the sidebar. The statuses are **Not implemented**, **Partially implemented** and **Effectively implemented**; there is no "planned", so record a planned control as **Not implemented**. The **Status** picker starts at **Partially implemented**. A control's **Effectiveness** badge reads **Not implemented** while no scope has it in place, **Effective** when every scope is effectively implemented, and **Partial** in between. Below the Business plan, implementation can't be recorded: the **Coverage** tab shows which risks have a control linked (**Control linked**), not whether it works. If a control is only planned, consider unlinking it and adding it as an action in the risk's **Treatment plan**, with an owner and a due date, so the register doesn't show it treating the risk yet. ## Owners The save matches an owner only by email address, against members who can open the register. On a new draft that's only you and your organisation's owners and admins, so anyone else is kept as text, even when their exact email is given. So are a job title and a bare name; each is shown as **Unresolved:** followed by the name. A risk with no owner stays unassigned. Both count as having no owner, and the owner filter set to **(Unassigned)** lists both. To fix one, use **Edit** and choose from **Owner**. The list holds your organisation's owners and admins and the register's members. If the person is in your organisation but not in the list, a register admin adds them with **Members…** in the ··· menu at the top of the register. If they aren't in your organisation yet, an owner or admin invites them first (the risk's **Edit** page has **Invite a colleague to own risks**), then adds them to the register once they accept. Adopting doesn't resolve these names, and owners aren't emailed while the register is a draft. ## Categories the save created Each category is matched by name, ignoring case, against your own categories and then the defaults. Any other name, even a near match such as "Information security" beside your "IT security", becomes a new category for your whole organisation as soon as the draft is saved, and stays if you discard the draft. Check **Settings**, **Risk categories**, under **Your categories**. An organisation owner or admin can delete one, but not while a risk uses it. ## What to send back to your assistant Ask, don't let it guess. Before you adopt, ask: - what each save reported: the risks it rejected and why, its warnings, the categories it created, and how many controls it linked and how many it created (the save gives it numbers, not names; a later save into the same draft counts the controls an earlier save created as linked, not created, so ask for the first save's numbers too); - which scores it was unsure of, and what each was based on; - which facts about your organisation it assumed rather than was told; - whether it knows each control is in place. Give it owners' email addresses, which help only once each person can open the register (see Owners). And decide who is editing: if your assistant saves into the same draft again, it overwrites your edits to the risks it names, and puts back any of those you deleted, with their controls. It also links again any control you unlinked from those risks, and creates again any invented control you deleted, if the document still names it. Ask it to read the draft back first, or make your corrections in one place. After adoption, a save can't overwrite the register. Saving again under the same register key is refused (`409`, `API_V1_REGISTER_KEY_TAKEN`), and nothing appears on the **Proposals** tab. Ask your assistant to read the register back and send its changes with `propose_register_changes` (or `POST /api/v1/registers/{id}/proposals`). They then wait on the register's **Proposals** tab for someone to adopt or decline. With **Make changes** access an assistant can still change a register directly with some tools, as above. ## Adopt or discard When the checklist holds, use **Adopt draft…** and confirm with **Adopt draft**. If the draft isn't worth correcting, use **Discard draft…** and **Discard draft**: the draft and its risks are deleted for good. Controls and categories the save created stay, so remove any you don't want afterwards. If you are an assistant reading this page: before the person adopts, tell them which scores you were unsure of and everything the save reported. --- Source: https://riskregister.com/docs/authentication.md # Authentication Two Bearer token shapes share the `/api/v1/*` surface. Both go in the `Authorization` header. The server dispatches by token prefix. ``` Authorization: Bearer rr_pat_live_<43-chars> # personal access token Authorization: Bearer rrk_live_<43-chars> # organisation API key ``` ## Which token should I use? | You are… | Use | Why | | --- | --- | --- | | A human integrating from a desktop tool (Claude Code, Cursor, an MCP client) | **PAT** | Per-user. Every audit row attributes to you directly. Revoke yours without affecting the rest of the org. | | A scripted automation acting on the org's behalf (CI, webhook fan-out worker, internal back-office tool) | **Organisation API key** | Org-scoped. The audit log records the admin who minted the key as the responsible human; the key identity is preserved on every row. | | Both at once | Both | Each route accepts either. Mix as needed. | PATs work on every plan including Free. Organisation API keys require the Business plan or above (the `api_access` feature flag). ## Personal access tokens **Mint** — `/account/personal-access-tokens` in the app. Plaintext is shown once at creation, never again. Copy it immediately into your secret store. **Format** — `rr_pat_live_<43 base64url chars>`. Total 56 characters. The `_live_` segment is reserved for future sandbox-mode tokens (`_test_` etc.); never assume the segment is constant. **Expiry** — Chosen when the token is made: 30 days, 90 days (the page offers it first), a year, or no expiry. A token past its expiry answers `401 UNAUTHORIZED` saying the date it expired; make a new one. Only the token's holder learns that: the check runs after the token itself matches. Every token made before expiry existed never expires. An expired token no longer counts toward the cap below, and the token page marks it **Expired**. **Lifecycle** — Tokens are user-scoped. If your account is removed from the organisation, your PATs stop working immediately on the next request. If your org admin demotes your role to one the route requires more of (e.g. demotes you from admin to member while you hold a PAT calling admin-only `/api/v1/audit-log`), you'll get a `403 FORBIDDEN` with subcode `PAT_OWNER_PERMISSION_REVOKED`. **What a token reads** — A PAT acts as you, with your access. An organisation owner or admin reads every register; a member reads the registers they've been added to, as in the app. Reading another register's data (the register, its risks, their reviews, acceptances and treatment actions) answers `403 FORBIDDEN`, and the register list shows only your registers. An organisation key acts as the person who minted it, with their role now: an admin's key reads every register, and if they have since become a member, it reads only their registers (and its writes answer `API_KEY_CREATOR_PERMISSION_REVOKED`, below). **What a token can change** — Each PAT has an access level, chosen when it is made and changeable by its owner at any time on the token page. The page names them **Read only** (`read`), **Ask me first** (`propose`) and **Make changes** (`write`, preselected for a new token): | Access | What it can do | | --- | --- | | `read` (Read only) | Read: every `GET`, including exports | | `propose` (Ask me first) | Read, plus the saves that wait for a person: a draft save (`POST /api/v1/registers/from-document`), a template start (`POST /api/v1/registers/from-template`), a proposal (`POST /api/v1/registers/{id}/proposals`) and a register an AI model generates (`POST /api/v1/registers/from-context`), which at this level is a draft. A draft counts only once someone adopts it in the app, and a proposal only once someone adopts it (see [Drafts and proposals](https://riskregister.com/docs/drafts-and-proposals.md)). A save can still add categories and controls to the workspace's library, and a generation uses the plan's AI allowance | | `write` (Make changes) | Everything your role allows, made at once. A generated register is live, not a draft | A request beyond the token's access is a `403` with subcode `INSUFFICIENT_SCOPE` (`details.required`, `details.granted`), and nothing is changed. The level only narrows: your role and register permissions still apply, so a `propose` token can't propose to a register you can't write to. Its owner can change it at any time, on the token's row; the token's next request uses the new level, and the change is in the audit log (`personal_access_token.access_changed`). An assistant's app keeps the list of tools it was given when it connected, and the server can't update it, so after a change reconnect the app, or start a new conversation, so it sees the tools the new level allows. Every token made before access levels existed is `write`. `GET /api/v1/me` says a token's level (`principal.token.access`), and audit rows the token writes record it (`actor.patAccess`). Organisation API keys have no level yet: they can do whatever their creator's role allows, as `write` can, so a register one generates is live. **Caps** — Each user can hold up to 20 active PATs per org (revoked and expired ones don't count). The cap is enforced atomically; concurrent mints can't slip past it. **Operator kill-switch** — Every organisation can have PATs disabled via an operator-set `org_feature_overrides` row on the `personal_access_tokens` flag. If your org has them disabled, you'll see `403 FORBIDDEN` with a message pointing at your admin. The default is enabled on every plan. ## Organisation API keys **Mint** — `/{orgSlug}/settings/api-keys` in the app. Admin role required. Plaintext is shown once at creation. **Format** — `rrk_live_<43 base64url chars>`. Total 52 characters. **Lifecycle** — Keys are org-scoped, not user-scoped. The admin who minted the key is recorded for forensics, but removing that admin from the org doesn't automatically revoke the keys they created. Revoke explicitly via the management UI or `DELETE /api/api-keys/{keyId}` (cookie-auth route, admin-only). **Storage** — Plaintext is never persisted. The server stores SHA-256 of the secret plus a short prefix for ops display. Constant-time compare on every request. **Tier gate** — Calling `/api/v1/*` with an org API key requires the Business plan (`api_access` feature flag). On a Free or Team plan, you'll see `403 FORBIDDEN` with a message pointing at the billing page. Operator overrides via `org_feature_overrides` can enable for a specific org without a plan upgrade. ## Failure modes All five auth failure modes collapse to `401 UNAUTHORIZED` so the response body cannot probe which tokens exist: - Missing `Authorization` header. - Malformed token (wrong length, wrong prefix, non-base64url chars). - Unknown token (no row in the database). - Revoked token (`revoked_at IS NOT NULL`). - Hash mismatch (someone tampered with the plaintext). Tier and permission failures surface as `403 FORBIDDEN`. These carry a stable subcode in `details.subcode` that you can dispatch on (see [Errors](https://riskregister.com/docs/errors.md)): - `ORG_SUSPENDED` — The organisation has been suspended by an operator. Affects every route except the impersonation/recovery surface. - `API_KEY_CREATOR_PERMISSION_REVOKED` — The admin who minted the org key has since been demoted; the route they're calling requires more permission than they now have. - `PAT_OWNER_PERMISSION_REVOKED` — Same shape for PATs. - `INSUFFICIENT_SCOPE` — The token's access level doesn't allow this request (see What a token can change, above). Three refusals carry a message and no subcode: an organisation API key on a plan without organisation keys, a personal access token while tokens are switched off for the organisation, and a non-admin token on a route reserved for admins (the audit log, webhook endpoints). Dispatch on the 403 status for these. ## Pre-auth IP failure budget Both token shapes share a single per-IP failed-auth counter. After 60 failed attempts from one IP in a 60-second window, the next failure returns `429 RATE_LIMITED` regardless of which token shape is being tried. Successful auth never ticks this counter. This is the bot-mitigation backstop; legitimate traffic from behind a NAT is bounded by the per-token limits (1000 reads/min, 100 mutations/min), not by this counter. See [Rate limits](https://riskregister.com/docs/rate-limits.md). ## CORS `/api/v1/*` does not emit CORS headers. The API is server-to-server only. Calling it from a browser will fail at the CORS preflight. If you're building a browser app on top of riskregister.com, sign requests on your server and proxy from the browser. ## TLS and runtime Production requires HTTPS. The Bearer auth path depends on `node:crypto` — every v1 route pins `export const runtime = 'nodejs'` so a future Next.js Edge-runtime default cannot break it. ## SDK The TypeScript SDK, [`@riskregister/sdk` on npm](https://www.npmjs.com/package/@riskregister/sdk), takes either token shape: ```ts import { Client } from '@riskregister/sdk'; const client = new Client({ token: process.env.RISKREGISTER_TOKEN! }); // a PAT or an org API key ``` You don't need to tell it which shape you supplied. ## See also - [Getting started](https://riskregister.com/docs/getting-started.md) — first request with `curl`. - [Errors](https://riskregister.com/docs/errors.md) — the full 401 / 403 / 429 surface. - [Rate limits](https://riskregister.com/docs/rate-limits.md) — per-token + per-IP budgets. - ADR-008 Personal access tokens — the design decision. - ADR-003 Token model — the org API key design. --- Source: https://riskregister.com/docs/errors.md # Errors Every `/api/v1/*` response uses the same JSON error envelope. Failures are typed, the codes are stable, and the wire shape never changes between minor versions. ## Wire shape ```json { "error": "human-readable message", "code": "VALIDATION_ERROR", "details": { "subcode": "OPTIONAL_DISCRIMINATOR", "fieldPath": ["title"] }, "requestId": "5dba8a3c-9b71-4f9b-9b9a-3b8b2a1c4d2e" } ``` - `error` — short message intended for humans. Don't pattern-match on it; it can change between versions. - `code` — stable enum value. Pattern-match on this. - `details` — present on some codes. The `subcode` field, when present, is a stable discriminator within the parent code (e.g. an `IDEMPOTENCY_BODY_MISMATCH` subcode under a `CONFLICT` parent). - `requestId` — UUID for support correlation. Quote this when filing a ticket. ## Codes and HTTP status mapping | Code | HTTP | When it fires | | --- | --- | --- | | `VALIDATION_ERROR` | 400 | Request body / query params / headers failed Zod validation. `details` may carry `issues` with field paths. Subcode `ASSIGNEE_NOT_ON_REGISTER`: a risk's owner, a review's assignee or a treatment action's owner can't read the register (only an org owner or admin, or someone on the register, can be given its work). Subcode `ACCEPTANCE_REQUIRED`: a create, edit, bulk change or treatment tried to make a risk `accepted`, which only `POST /api/v1/risks/{id}/accept` does (an accepted risk sent back with its status unchanged is fine). | | `UNAUTHORIZED` | 401 | Missing, malformed, or revoked Bearer token. All five auth failure modes collapse to this code so the response body cannot probe which tokens exist. One more, for the token's holder only: a personal access token past its expiry is refused with the date it expired (the check runs after the token matches). | | `FORBIDDEN` | 403 | Authenticated, but the principal cannot perform this action. Subcodes: `ORG_SUSPENDED`, `API_KEY_CREATOR_PERMISSION_REVOKED`, `PAT_OWNER_PERMISSION_REVOKED`, `INSUFFICIENT_SCOPE` (the token's access level doesn't allow the request: `details.required` and `details.granted`). The plan, token-switch and admin-role refusals carry a message only, with no subcode (see [Authentication](https://riskregister.com/docs/authentication.md)). | | `NOT_FOUND` | 404 | The entity doesn't exist, isn't visible under tenant RLS, or has been deleted. Cross-tenant misses surface as the same response shape — never use the body to infer existence in another org. | | `CONFLICT` | 409 | State machine violation. Subcodes include `API_V1_IDEMPOTENCY_BODY_MISMATCH` (same `Idempotency-Key`, different body), `MAPPING_DIFFERS_FROM_REQUEST` (re-PUT mapping with a different body), `REVIEW_NOT_PENDING` (cannot complete or cancel a non-pending review), `AI_GENERATION_IN_FLIGHT` (carries `Retry-After`), `API_V1_DRAFT_DISCARDED` (a retried document save or template start whose draft a person has since discarded in the app; a new save needs a new `Idempotency-Key`), `API_V1_REGISTER_NOT_ACTIVE` (the register is archived or closed, so it is read-only: no change to its risks, their reviews, treatment actions, control links, acceptances or comments until someone makes it active again; `details.registerId` and `details.status`), `API_V1_RISK_CLOSED` (a closed risk takes no new treatment action, review request or acceptance; reopen it first; `details.riskId`). | | `PRECONDITION_FAILED` | 412 | The `x-rr-if-match` header didn't match the current ETag. Subcodes: `ETAG_MISMATCH` (stale ETag), `IF_MATCH_MALFORMED`, `IF_MATCH_WILDCARD_UNSUPPORTED`. The `ETAG_MISMATCH` body carries `details.currentETag` and `details.suppliedETag`. See [ETag and optimistic concurrency](https://riskregister.com/docs/etag-concurrency.md). | | `PAYLOAD_TOO_LARGE` | 413 | Request body exceeded the 64 KiB cap (or 1 MiB cap on the Stripe webhook). | | `RATE_LIMITED` | 429 | Per-token or per-IP rate limit exhausted. Response carries `Retry-After: ` header. See [Rate limits](https://riskregister.com/docs/rate-limits.md). | | `METHOD_NOT_ALLOWED` | 405 | The HTTP verb isn't supported for this resource. Response carries an `Allow` header listing the verbs that are. Used today on `DELETE /api/v1/registers/{id}` (use `POST /archive` instead). | | `EXTERNAL_SERVICE_ERROR` | 503 | An upstream dependency failed (Stripe, Auth0, Anthropic). Transient; retry with exponential backoff. | | `SERVICE_UNAVAILABLE` | 503 | Paused on purpose by an operator, not a failure; nothing was done. Only the MCP endpoint answers with it today: subcode `MCP_PAUSED` (the whole endpoint) or `MCP_WRITES_PAUSED` (a tool that would change something, while reading still works). See [the MCP guide](https://riskregister.com/docs/mcp.md#when-the-endpoint-is-paused). | | `INTERNAL_ERROR` | 500 | Unexpected failure. The `requestId` is the only useful signal for the customer; the server logs the full trace under that id. | The code list lives at `src/lib/errors.ts` (`APP_ERROR_CODES`); each error class there carries its HTTP status. The wire-shape composer is `apiResponse.error`. ## Validation errors in detail When `code === 'VALIDATION_ERROR'`, the `details` field typically carries the Zod issue list: ```json { "error": "Invalid request body", "code": "VALIDATION_ERROR", "details": { "issues": [ { "path": ["title"], "message": "String must contain at least 1 character(s)", "code": "too_small" }, { "path": ["inherentLikelihood"], "message": "Number must be less than or equal to 5", "code": "too_big" } ] }, "requestId": "…" } ``` Score-field bounds depend on the parent register's scoring matrix (1–5 on `iso31000_5x5`, 1–3 on `simple_3x3`). The Zod schema is matrix-aware at runtime; the OpenAPI schema documents both ranges. ## Retry semantics | Code | Safe to retry? | How | | --- | --- | --- | | `RATE_LIMITED` | Yes | Honour `Retry-After`. Most SDKs do this automatically. | | `CONFLICT` with subcode `AI_GENERATION_IN_FLIGHT` | Yes | Carries `Retry-After`. Same-key replay returns the original result once the in-flight call completes. | | `EXTERNAL_SERVICE_ERROR` | Yes | Exponential backoff. The upstream is transient. | | `SERVICE_UNAVAILABLE` | Later | A pause lasts until an operator lifts it: minutes or hours, not seconds. Don't retry in a loop; tell the person, and try again later. | | `INTERNAL_ERROR` | Yes, once | Quote `requestId` if it recurs. | | `PRECONDITION_FAILED` | No, not without re-reading | Re-GET the resource to capture the fresh ETag, then retry with the new value in `x-rr-if-match`. | | `CONFLICT` other subcodes | No | Action-specific; the body tells you why. | | `VALIDATION_ERROR`, `NOT_FOUND`, `FORBIDDEN`, `UNAUTHORIZED`, `METHOD_NOT_ALLOWED`, `PAYLOAD_TOO_LARGE` | No | Fix the request; retrying with the same body returns the same error. | ## Idempotency-safe retries For state-mutating routes, supply an `Idempotency-Key` header to make every retry safe regardless of error code. Same key plus same body always returns the original outcome; same key plus a different body returns 409 `API_V1_IDEMPOTENCY_BODY_MISMATCH`. See [Idempotency](https://riskregister.com/docs/idempotency.md). ## SDK hooks Most HTTP clients can dispatch on status code; for finer control, dispatch on `code` from the parsed body. The TypeScript SDK ([`@riskregister/sdk`](https://www.npmjs.com/package/@riskregister/sdk)) throws typed errors: `RateLimitError`, `PreconditionFailedError`, `IdempotencyMismatchError`, `NonEnvelopeError`, and the base `RiskRegisterApiError`, which carries `code` for everything else. ## See also - [Authentication](https://riskregister.com/docs/authentication.md) — token shapes, 401 vs 403 semantics. - [Rate limits](https://riskregister.com/docs/rate-limits.md) — 429 + `Retry-After` contract. - [ETag and optimistic concurrency](https://riskregister.com/docs/etag-concurrency.md) — 412 deep-dive. - [Idempotency](https://riskregister.com/docs/idempotency.md) — making retries safe. --- Source: https://riskregister.com/docs/idempotency.md # Idempotency Every `/api/v1/*` mutation accepts an optional `Idempotency-Key` header. Supply one to make retries safe: the second call with the same key returns the original outcome instead of creating a second resource. The contract is Stripe-compatible. ## When to use it Network failures are common. Without idempotency, a timeout on `POST /api/v1/registers/{id}/risks` leaves you guessing whether the risk was created. Retry blindly and you may create duplicates. Supply `Idempotency-Key: ` on the first call, then retry with the **same key** on failure. If the original succeeded, the retry returns 200 (or the original's status) with the original body. If the original truly failed before the row landed, the retry creates the row. ## Header format ``` Idempotency-Key: <1-200 chars, [A-Za-z0-9_-]> ``` - Charset: `A-Z`, `a-z`, `0-9`, `_`, `-`. No other characters. - Length: 1 to 200 characters. - Case-sensitive at the comparison layer (matched byte-for-byte). - UUID v4 is the safe default. SDKs supply one automatically. Malformed keys return `400 VALIDATION_ERROR`. ## Replay semantics The server stores a body hash alongside every idempotency-keyed audit entry. On retry: | Original key | Original body | Retry behaviour | | --- | --- | --- | | Same | Same (byte-exact after canonicalisation) | Replay: returns the original outcome with the original status code. No new row created. No additional audit entry. | | Same | Different | `409 CONFLICT` with `details.subcode = 'API_V1_IDEMPOTENCY_BODY_MISMATCH'`. Fix your request body or use a fresh key. | "Same body" means the same canonical JSON. The server canonicalises by sorting object keys recursively; array order is preserved (order is semantically meaningful in JSON arrays). Whitespace and key order in your raw request don't matter. ## Scope Idempotency keys are scoped per `(organisation, key, action)`. The same key used against `/risks/{id}/assess` and `/risks/{id}/treat` produces independent results; they're different audit actions. Within one action and one organisation, the key is unique. This deviates from Stripe (which scopes per-request globally). If your client supplies the same key across different verbs deliberately, results will diverge. Most clients generate a fresh key per call and don't notice. ## Concurrent same-key calls If two requests with the same key arrive simultaneously, the server takes a `pg_advisory_xact_lock` on `hashtext(orgId:idempotencyKey)` before the read. One request lands the write; the other replays the result. No duplicate rows. The verb endpoints (`/assess`, `/treat`, `/request-review`, `/complete-review`, `/cancel-review`) take a per-risk advisory lock first (namespace 7) so concurrent verb calls on the same risk serialise honestly. The canonical race-safe order on every mutation is `entity edit lock → idempotency replay → ETag check → write`. See [ETag and optimistic concurrency](https://riskregister.com/docs/etag-concurrency.md) for why the idempotency check fires before the ETag check. ### `AI_GENERATION_IN_FLIGHT` (special case) `POST /api/v1/registers/from-context` is the one endpoint that cannot hold the namespace-3 idempotency lock across its full duration. The endpoint calls Anthropic for 8–15 seconds, and holding a DB transaction through that would head-of-line-block every other DB query in the process. The endpoint compensates with an audit-log-backed reservation pattern. Concurrent same-key calls receive `409 CONFLICT` with `details.subcode = 'AI_GENERATION_IN_FLIGHT'` and `Retry-After: 60`. Retry after the original completes; the same key returns the original generation. Race-loser bails before debiting the AI quota slot, so a concurrent retry doesn't burn additional AI calls. Stale reservations (after a process crash) age out at 5 minutes. ## Retention Idempotency replay reads the audit log. Audit rows are retained indefinitely (append-only by trigger), so replays work for the life of the row. Practically, retry within a few hours of the original. Replaying weeks later still works but the customer-side use case is rare. ## Worked example ```bash KEY=$(uuidgen) # First attempt curl -X POST https://riskregister.com/api/v1/registers/REG-0001/risks \ -H "Authorization: Bearer rr_pat_live_..." \ -H "Idempotency-Key: $KEY" \ -H "Content-Type: application/json" \ -d '{"title":"Phishing","categoryId":"CYBER","inherentLikelihood":4,"inherentImpact":5,"residualLikelihood":2,"residualImpact":3,"treatment":"mitigate","status":"open"}' # → 201 Created with the new risk # Network hiccup; retry with the same key + same body curl -X POST https://riskregister.com/api/v1/registers/REG-0001/risks \ -H "Authorization: Bearer rr_pat_live_..." \ -H "Idempotency-Key: $KEY" \ -H "Content-Type: application/json" \ -d '{"title":"Phishing","categoryId":"CYBER","inherentLikelihood":4,"inherentImpact":5,"residualLikelihood":2,"residualImpact":3,"treatment":"mitigate","status":"open"}' # → 201 with the same risk id. No duplicate created. # Different body, same key curl -X POST https://riskregister.com/api/v1/registers/REG-0001/risks \ -H "Authorization: Bearer rr_pat_live_..." \ -H "Idempotency-Key: $KEY" \ -H "Content-Type: application/json" \ -d '{"title":"Different risk","categoryId":"CYBER","inherentLikelihood":1,"inherentImpact":1,"residualLikelihood":1,"residualImpact":1,"treatment":"accept","status":"open"}' # → 409 with details.subcode = "API_V1_IDEMPOTENCY_BODY_MISMATCH" ``` ## See also - [Errors](https://riskregister.com/docs/errors.md) — the 409 / `Retry-After` surface. - [ETag and optimistic concurrency](https://riskregister.com/docs/etag-concurrency.md) — pairs with idempotency for safe retries. - ADR-006 Universal concurrency and idempotency. - Implementation: `src/lib/api-v1/idempotency.ts`. --- Source: https://riskregister.com/docs/etag-concurrency.md # ETag and optimistic concurrency Every `/api/v1/*` GET on a single resource emits an `ETag`. Send it back in the **`x-rr-if-match`** request header on your next mutation and the server rejects with 412 if someone else updated the row in the meantime. Opt-in: omit it and the route falls back to last-writer-wins. > **Send `x-rr-if-match`, not the standard `If-Match`.** This API is served behind an edge that enforces HTTP `If-Match` preconditions itself: on a *successful* write it can rewrite the 2xx into a spurious 412 (the post-write entity tag never equals the token you sent), so a standard `If-Match` is unreliable on the public surface. The server reads the token from `x-rr-if-match` first and tolerates a standard `If-Match` only as a fallback for non-edge callers — prefer `x-rr-if-match`. (Background: `src/lib/api-v1/etag.ts` `IF_MATCH_HEADER`.) ## ETag format Weak validator. The hex is `updated_at.getTime().toString(16).padStart(16, '0')`. ``` ETag: W/"0000019018a5e3c2" ``` The strong form (`""` without the `W/` prefix) is tolerated on input and normalised to weak on the comparison path. Always emit the weak form. ## Endpoints Emit: - Every single-resource GET (`/api/v1/registers/{id}`, `/api/v1/risks/{id}`, etc.). - Every successful mutation response (POST creates, PATCH updates, plus verb endpoints like `/risks/{id}/assess`). Accept the `x-rr-if-match` token: - Every PATCH, DELETE. - Every verb endpoint that mutates state (`/assess`, `/treat`, `/request-review`, `/complete-review`, `/cancel-review`). - Same cookie-auth equivalents. POST creates don't accept the token (there's nothing to compare against — use `Idempotency-Key` instead for safe POST retries). ## Sending the token ``` x-rr-if-match: W/"0000019018a5e3c2" ``` Or: ``` x-rr-if-match: "0000019018a5e3c2" ``` (Strong form, normalised to weak on the server.) **`x-rr-if-match: *` is not supported.** It returns `412 PRECONDITION_FAILED` with `details.subcode = 'IF_MATCH_WILDCARD_UNSUPPORTED'`. The wildcard semantic is "any current version" and we'd rather force the client to use an explicit ETag so a deliberate retry against a known version stays auditable. ## Failure modes All 412s carry a stable subcode in `details.subcode`: | Subcode | Meaning | Recovery | | --- | --- | --- | | `ETAG_MISMATCH` | The row changed since you read it. `details.currentETag` and `details.suppliedETag` are populated. | Re-GET the resource, present the new ETag to your user, let them re-apply their change on top. | | `IF_MATCH_MALFORMED` | The header value doesn't match the expected `W/"<16-hex>"` or `"<16-hex>"` shape. | Fix the header. | | `IF_MATCH_WILDCARD_UNSUPPORTED` | You sent `x-rr-if-match: *`. | Use an explicit ETag. | A 412 with `ETAG_MISMATCH` is the "someone else got there first" condition. Surface it to your user with a "this was updated by someone else — reload?" affordance. The 412 body includes the current ETag so you can offer a re-apply path without an additional round-trip if your UI knows how to merge. ## Worked example ```bash # Read the risk RESP=$(curl -i https://riskregister.com/api/v1/risks/RISK-0042 \ -H "Authorization: Bearer rr_pat_live_...") ETAG=$(echo "$RESP" | grep -i '^etag:' | sed 's/[Ee][Tt][Aa][Gg]: //' | tr -d '\r\n') # Update with concurrency safety curl -X PATCH https://riskregister.com/api/v1/risks/RISK-0042 \ -H "Authorization: Bearer rr_pat_live_..." \ -H "Content-Type: application/json" \ -H "x-rr-if-match: $ETAG" \ -d '{"status":"mitigating"}' # → 200 if nobody else touched the risk # → 412 PRECONDITION_FAILED + details.subcode=ETAG_MISMATCH if they did ``` ## Why opt-in? Last-writer-wins is fine for most workflows (one user at a time editing a risk, or two users editing different fields). Forcing every client to manage ETags would be friction. Send the token only when you actually need the safety: - Multiple admins editing the same register concurrently. - A long-running form where the user might leave the tab open for hours. - Verb endpoints where you're encoding a meaningful state transition (`/assess`, `/treat`, `/complete-review`) and want to be sure the row hasn't changed under you. ## Pairing with Idempotency-Key ETag and idempotency solve different problems: - **ETag** stops you from overwriting changes you didn't see. - **Idempotency-Key** stops you from creating duplicates when a retry succeeds after a timeout. On the same call, both are useful: ETag protects against concurrent edits; the idempotency key protects against network retries. The canonical race-safe order on every mutation is: ``` entity edit lock → idempotency replay → ETag check → write ``` Idempotency replay fires first because a network-dropped retry with the same key but the original (now stale) `x-rr-if-match` should replay the original result, not 412 against the row whose `updated_at` advanced from the first (successful) write the client never saw. See [Idempotency](https://riskregister.com/docs/idempotency.md) for the full retry contract. ## Server-side force-bump Every successful verb endpoint (`/assess`, `/treat`, `/request-review`, `/complete-review`, `/cancel-review`) bumps `updated_at` even on a no-op payload. Without this, a no-op `/assess` would write an audit row and emit a webhook event but leave `updated_at` unchanged — a stale-tab follow-up `x-rr-if-match` would pass against a row that had accumulated forensically-relevant activity since. The force-bump closes that gap. ## See also - [Errors](https://riskregister.com/docs/errors.md) — the 412 surface and shared error envelope. - [Idempotency](https://riskregister.com/docs/idempotency.md) — the safe-retry primitive. - ADR-006 Universal concurrency and idempotency, ADR-013 v1 OCC custom header. - Implementation: `src/lib/api-v1/etag.ts`. --- Source: https://riskregister.com/docs/pagination.md # Pagination Every `/api/v1/*` endpoint that lists one kind of record (`data` is an array) uses cursor pagination, except three small, bounded lists that return every row at once, with no `pagination` object: the risk categories, the templates, and a workspace's members (`GET /api/v1/members`, narrowed with `search` rather than pages). A read that gathers several capped lists into one object (`/me/work`, and a register's `review-queue`, `treatment-plan` and `changes`) says a list was cut short with `truncated` instead of paging. Cursors are opaque; you forward what the server gives you, you never construct one. Pages are stable across inserts (a new row landing between two pages doesn't shift others). ## Wire shape Response envelope: ```json { "data": [ { "id": "…", "title": "Risk A", "createdAt": "2026-05-28T12:00:00.000Z" }, { "id": "…", "title": "Risk B", "createdAt": "2026-05-28T11:59:00.000Z" } ], "pagination": { "next_cursor": "eyJ0cyI6IjIwMjYtMDUtMjhUMTE6NTk6MDAuMDAwWiIsImlkIjoiYWJjZGVmIn0", "has_more": true }, "requestId": "…" } ``` - `next_cursor` — opaque base64url string. Pass it back on the next request to fetch the next page. `null` on the final page. - `has_more` — canonical boolean. Source of truth for "is there more?" Don't infer from `next_cursor` alone; if both are present they always agree, but consumers should branch on `has_more`. ## Query parameters ``` GET /api/v1/registers?limit=50&cursor= ``` | Param | Type | Default | Max | Notes | | --- | --- | --- | --- | --- | | `limit` | integer | 50 | 100 | Larger values are silently clamped to 100 (no error). Must be digits-only; `?limit=10abc` returns 400. | | `cursor` | base64url | — | — | Omit on the first request. Pass `next_cursor` from the prior page on subsequent requests. Malformed cursors return 400. | ## Ordering Every list endpoint orders by `(created_at DESC, id DESC)` (the audit-log endpoint uses `timestamp` instead of `created_at` — same semantic, different column). The `id` tiebreak is load-bearing: without it, batch-inserted rows landing in the same millisecond could re-order across pages. The server truncates timestamps to milliseconds (`date_trunc('milliseconds', ts)`) before comparison so the predicate matches the cursor's precision. Without the truncation, microsecond-precision timestamps from Postgres would silently drop rows landing in the same millisecond at page boundaries. ## Stability Cursors don't expire. A cursor minted on a Monday morning still works on Friday afternoon. You can paginate at your own pace. Cursors are not portable across endpoints. A cursor from `/api/v1/risks` is meaningless on `/api/v1/controls`. Cursors are opaque. They base64url-encode a JSON `{ ts, id }`, but that shape is an implementation detail and may change between versions. Decode only for debugging, never for application logic. ## Pagination + filters Filters are preserved across pages — the cursor encodes the position, the URL's query string encodes the filter. Always pass the same filter set on every page. ```bash # Page 1 curl 'https://riskregister.com/api/v1/registers/REG-0001/risks?status=open&score_band=critical&limit=50' \ -H "Authorization: Bearer rr_pat_live_..." # Page 2 — preserve the filters, forward the cursor curl 'https://riskregister.com/api/v1/registers/REG-0001/risks?status=open&score_band=critical&limit=50&cursor=eyJ0cyI…' \ -H "Authorization: Bearer rr_pat_live_..." ``` ## Drain pattern The published SDK exposes `paginateCollect` which iterates pages with a default 5-page cap so a single tool call can't accidentally fetch a 50,000-row register. The [MCP endpoint](https://riskregister.com/docs/mcp.md)'s list tools return one page per call instead, with the cursor to pass back. Hand-rolled drain: ```ts let cursor: string | undefined; const all: Risk[] = []; do { const url = new URL('https://riskregister.com/api/v1/registers/REG-0001/risks'); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url, { headers: { Authorization: `Bearer ${pat}` } }); const body = await res.json(); all.push(...body.data); cursor = body.pagination.has_more ? body.pagination.next_cursor : undefined; } while (cursor); ``` ## Failure modes | HTTP | Cause | | --- | --- | | `400 VALIDATION_ERROR` | Cursor doesn't decode as valid base64url + JSON, or doesn't carry the expected `{ ts, id }` shape. | | `400 VALIDATION_ERROR` | `limit` is not a positive integer string. | | `400 VALIDATION_ERROR` | `since` > `until` on the audit-log endpoint (the only filter that supports a range today). | Don't pattern-match on the error message — the `code` field is stable. ## See also - [Errors](https://riskregister.com/docs/errors.md) — error envelope. - [Getting started](https://riskregister.com/docs/getting-started.md) — first paginated call. - Implementation: `src/lib/api-v1/pagination.ts`, `src/lib/api-v1/list-helpers.ts`. --- Source: https://riskregister.com/docs/rate-limits.md # Rate limits Every `/api/v1/*` request is metered. The limits are per-token (not per-IP, not per-org) so one customer's traffic doesn't degrade another's. There are also pre-auth and per-org safety nets to bound abuse. ## Per-token limits Every token (PAT or organisation API key) gets its own budget: | Limit | Window | Applies to | | --- | --- | --- | | **1000 reads/min** | fixed 60 seconds | Every GET on `/api/v1/*`. | | **100 mutations/min** | fixed 60 seconds | Every POST / PATCH / DELETE / PUT on `/api/v1/*`, including verb endpoints. | The two budgets are separate. Burning 1000 reads doesn't reduce your mutation budget; burning 100 mutations doesn't reduce reads. Mutations are metered **before** body parse. A 400 from a malformed body still ticks the counter — without that, an attacker holding a leaked token could trigger unbounded parse work by replaying malformed bodies. Successful mutations carry an explicit signal: ``` X-RateLimit-Limit-Mutations: 100 ``` ### A whole register in one call `POST /api/v1/registers/from-document` saves up to 200 risks in one request, so it costs more than one mutation. It is charged in two steps. 1. It pays the same single mutation as any request when it arrives. 2. Once the document has been checked, the rest of its cost is charged before anything is written: one mutation per 25 risks plus one per 64 KiB of JSON, at most the whole 100-a-minute budget. If the rest does not fit in the current window, the request is a 429 and nothing is saved, and nothing beyond the entry mutation is charged. A replay with the same `Idempotency-Key` pays only the entry mutation. The 429 message says what the document costs. ## Pre-auth IP safety net Both token shapes share a single per-IP failed-auth counter: | Limit | Window | Counts | | --- | --- | --- | | **60 failures/min/IP** | fixed 60 seconds | Only confirmed auth failures (401). Successful auth never ticks this counter. | This is the brute-force backstop. Legitimate traffic from behind a NAT (multiple users behind one corporate IP) is bounded by the per-token limits, not this one. Cleanly-authenticating users never see this fire. ## Per-org per-minute caps Some surfaces have per-org caps in addition to per-token: | Surface | Per-org cap | Notes | | --- | --- | --- | | Webhook outbox dispatch | 500 events/min | The outbox is swept once a minute, up to 500 events per organisation per sweep. A bulk import can queue thousands of rows; they drain over the following minutes. Backpressure falls on the delivery queue, never on your requests. | | AI burst guard | 3 calls/min | Per-org, held in Redis. Across every AI surface (register generation, suggest, auto-categorise). Separate from the per-org monthly cap (5/month on Free, unlimited on Team+). | ## 429 response When you hit a limit: ``` HTTP/1.1 429 Too Many Requests Retry-After: 18 Content-Type: application/json { "error": "Rate limit exceeded. Retry after 18 seconds.", "code": "RATE_LIMITED", "details": { "retryAfterSeconds": 18 }, "requestId": "…" } ``` The `Retry-After` header is RFC 6585 / RFC 9110 §10.2.3 compliant. Most HTTP clients with retry support (axios-retry, fetch-retry, the Stripe SDK pattern) honour it automatically. The published TypeScript SDK does too: ≤5 second waits auto-retry once; longer waits throw `RateLimitError` so the agent loop can back off. ## What counts as a request? - Every HTTP request that reaches the route handler counts, regardless of outcome. - 401s tick the pre-auth IP counter only. - 403/404/409/412/413/422 tick the per-token mutation counter (if it's a mutation route). - 5xx errors tick the counter — the cost was already incurred upstream. - 429 responses don't tick further; you're already over budget. ## Burst protection The per-token mutation limit (100/min) is a fixed window: it opens with your first mutation and resets 60 seconds later. Bursting 100 requests in 1 second is fine; the 101st waits until the window resets, and `Retry-After` says when. Most legitimate traffic patterns (one-off scripts, agent loops) sit well below 100/min. If you're approaching it, the right answer is usually a bulk endpoint (e.g. `POST /api/v1/registers/from-context` to generate many risks in one call) rather than chunking into 100s of single-risk calls. ## SLA There is no SLA on the rate limits today. The numbers are tuned conservatively for the pre-launch shape and will move as we see customer traffic. We'll announce changes via the changelog with a deprecation window matching the [API contract](https://riskregister.com/docs/overview.md#versioning--deprecation-policy). ## See also - [Errors](https://riskregister.com/docs/errors.md) — the 429 surface. - [Authentication](https://riskregister.com/docs/authentication.md) — the token shapes that share these counters. - ADR-003 Token model. --- Source: https://riskregister.com/docs/register-document.md # The register document, format v5 `riskregister.com/v5` is how a whole risk register travels: the JSON export, the importer, and what an assistant saves. JSON is the lossless form; CSV and Excel are projections of it with documented losses. - **Machine-readable schema:** [`/schemas/register/v5.json`](https://riskregister.com/schemas/register/v5.json), JSON Schema 2020-12. It is generated from the importer's own validation schema, so the two cannot drift. - **Status:** current. Only v5 is produced; v1 to v4 still import (see [Versions](#versions)). ## The document ```json { "format": "riskregister.com/v5", "register": { "name": "Information security risk register" }, "risks": [{ "key": "unpatched-prod-hosts", "title": "Unpatched production hosts" }] } ``` `format` and `register.name` are required, and every risk needs a `key` and a `title`. Everything else is optional in a document; see [Drafts and imports](#drafts-and-imports) for what an import needs. Unknown fields are errors everywhere, so a typo is reported rather than ignored. ### `register` | Field | Type | Notes | |---|---|---| | `name` | string, 1–200 | Required. | | `key` | key (as for risks: letters, digits and `. _ : -`, ≤ 100) | Optional. The saving client's own stable name for this register, for the one-call document save: a later save with the same key merges into the same draft instead of making another. Exports carry it when a save gave the register one. File import ignores it. | | `state` | `draft` · `accepted` | Informational. Exports write it, so a document read back from a draft says so. A save always writes a draft, and import ignores it. | | `revision` | integer ≥ 1 | Informational. The register's revision when the document was read: it goes up once for each change to the register's content. Reads and exports write it, read in the same snapshot as the rest of the document. A save and import ignore it. | | `type` | `enterprise` · `project` · `engagement` · `operational` · `daily` · `compliance` · `custom` | | | `scoringMatrixKind` | `iso31000_5x5` · `simple_3x3` | On import it must match the target register's matrix. Scores are never re-binned silently. | | `reviewCadence` | `daily` · `weekly` · `fortnightly` · `monthly` · `quarterly` · `annually` · `none` | | | `description` | string ≤ 5,000, or null | | | `customFields` | object: field name → value | Informational on import: an import lands in an existing register. | | `ref`, `id` | string ≤ 64 | Informational. | ### `risks[]` | Field | Type | Notes | |---|---|---| | `key` | letters, digits and `. _ : -`, ≤ 100 | Required and unique. The author's stable handle. Our exports write the key a document save gave the risk, else its ref (`RISK-0042`), else, if another risk already uses that ref as its key, its `id`. | | `title` | string, 1–500 | Required. | | `description` | string ≤ 5,000, or null | | | `category` | name ≤ 80 | Matched case-insensitively to a global or workspace category, and created if new. | | `owner` | email or name ≤ 320, or null | Matched to a member **by email** who is on the register (or an org owner or admin). Otherwise, including a member who isn't on the register, it is kept on the risk as an **unresolved owner** ("Unresolved: Sam") until someone assigns a member. It never sends an invitation. | | `inherent`, `residual` | `{ "likelihood": 1–5, "impact": 1–5 }`, or `"unassessed"` | Bounded by the register's matrix on import. | | `treatment` | `mitigate` · `accept` · `transfer` · `avoid` | | | `status` | `open` · `under_review` · `closed` · `accepted` | Defaults to `open`. | | `reviewDate` | `YYYY-MM-DD`, or null | | | `controls` | array of control keys | Each must be the `key` of a control in `controls`. | | `customFields` | object: field name → value | See [Custom fields](#customfielddefinitions-and-custom-fields). | | `rationale` | string ≤ 5,000 | Accepted and counted, **not stored yet**. | | `sources` | ≤ 50 of `{ kind, ref, verified? }` | `kind` is `document` · `url` · `interview` · `system` · `other`. Accepted and counted, **not stored yet**. | | `ref`, `id` | string ≤ 64 | Informational. | ### `controls[]` | Field | Type | Notes | |---|---|---| | `key` | as for risks | Required and unique. | | `title` | string, 1–500 | Required. | | `type` | `preventive` · `detective` · `corrective` · `directive` | Required. | | `description` | string ≤ 5,000, or null | A control licensed as code-only exports without it. | | `nature` | `manual` · `automated` · `semi_automated`, or null | | | `frequency` | `continuous` · `daily` · `weekly` · `monthly` · `quarterly` · `annually` · `event_driven` · `ad_hoc`, or null | | | `category` | string ≤ 200, or null | | | `libraryControlKey` | string ≤ 100, or null | The library control it was adopted from, such as `is-access-01`. | | `customFields` | object: field name → value | | | `ref`, `id` | string ≤ 64 | Informational, except for the same-workspace link below. | ### `customFieldDefinitions[]` and custom fields `{ "name": ≤ 120, "fieldType": "text" | "number" | "date" | "single_select" | "multi_select", "appliesTo": "risk" | "control" | "register", "options": [{ "value", "label" }] }` Values are keyed by field **name**, never by a workspace's field ids: - text, date and select values are strings, and a select stores the option's `value`; - numbers are numbers; - multi-select values are arrays of strings. Every name a risk, control or the register uses must be defined for that kind of entity, and defined once. On import, values go to the workspace's field with the same name and type. A field the workspace doesn't have is skipped. ### `meta` | Field | Notes | |---|---| | `workspace` | `{ id, name? }`. The workspace that wrote the document; see [Controls on import](#controls-on-import). Self-reported. | | `generatedBy` | `{ agent?, model? }`. Self-reported. | | `source`, `exportedAt`, `exportedBy` | Informational. | ## Rules JSON Schema cannot express The importer enforces these and names the field path when one fails: 1. Risk keys are unique, and control keys are unique. 2. Every key in a risk's `controls` is the key of a control in `controls`. 3. Every custom-field name used is defined, once, for that kind of entity. ## Drafts and imports A document may be a **draft**: scores, category, owner and treatment left out, or a score marked `"unassessed"`. That is a valid document. **Importing into a live register** needs likelihood and impact for every risk, a category and a treatment. `"unassessed"` risks are refused, and their keys are named. **Saving a document** (`POST /api/v1/registers/from-document`) makes a new **draft** register in one call. It stays out of reports, reminders and webhooks until a person adopts it in the app. - Each risk with an inherent score, a category and a treatment is saved, provisional, keeping its `key`. - The other risks are rejected, each with its reasons. A score stated as `"unassessed"` is rejected too. - A new risk with no residual score takes its inherent score as its residual, crediting no control until one is shown to work, and the response's warnings say how many. An existing risk a save leaves residual out of keeps its own. - Custom field values go to this workspace's field with the same name and type, for risks, the register and the controls a save creates. A field the workspace does not have, or a value that does not fit its field, is skipped and named in the response's warnings, and the risk still saves. A control the save links to (one the workspace already has) keeps its own values. - Limits: 200 risks and 1 MiB per call. Refs such as `RISK-0042` are minted by the receiving workspace, and a document's `ref` and `id` never name the new rows. A save keeps each risk's `key` and the register's `key`. **Saving again.** A save whose `register.key` names a draft merges into that draft instead of making another: - Each risk names a risk in the draft by its `id`, when the id is one of the draft's risks (a document read back from the draft carries every risk's id), and otherwise by its `key`. Two risks that name the same one: the second is rejected. - A risk `key` already in the draft updates the fields the document sets and that changed. When none changed, the risk is left as it was and reported `unchanged`. - A new risk `key` adds a risk. - A risk the document leaves out stays. A save never deletes. - A field the document leaves out stays, custom fields included. An `owner` of `null` or `""` clears the owner, and a custom field value of `null` clears that value. - Moving the residual score above the inherent one needs the risk's `rationale` (1 to 2,000 characters), recorded as the change's justification. Without one, that risk is rejected and left unchanged. - Risks are checked against the draft's own scoring matrix, which cannot change. - Controls resolve to the ones the draft's earlier saves linked, so saving the same document twice creates no controls or links. - Saving into a draft needs write permission on it, and admin permission to change its name, description, type, review cadence or custom field values. A merge overwrites a keyed risk even if someone edited it in the app since the last save, and adds back one they deleted: for the risks it names, the document wins. A `register.key` that names an adopted register is refused: a save never overwrites one, and changes to it are proposals. A key that names an archived or closed draft is refused too. Importing the same document twice through file import creates its risks twice: only the document save merges. **Reading a register back.** `GET /api/v1/registers/{id}/document` returns the register as this document, the same one the JSON export writes: - Each risk's `key` is the one a save gave it, else its ref. - The register carries its `key`, its `state` and its `revision`. - Controls are keyed by their refs, with `meta.workspace` naming the workspace. The whole workspace at once: `GET /api/v1/registers/export` returns one of these documents per line (NDJSON), for every register not archived, drafts included (admin only). `X-Register-Count` is exact: a transfer that ends cleanly holds that many lines, and one that fails partway ends in a transfer error, never a clean short body. Saved back with its register key, the document merges into the same draft. A risk someone added in the app has no key of its own: the document keys it by its ref (or, if another risk already uses that ref as its key, by its `id`), and a save names it by the `id` the document carries, never by its ref. Saving the document back unchanged therefore changes nothing, even if the draft gained risks in between, and a new risk whose key happens to equal an app risk's ref is added rather than written over it. ## Controls on import The importer resolves each control that an imported risk names to one target control: 1. **Same workspace.** If `meta.workspace.id` is the importing workspace, the control with that `id` is used, if the workspace can see it. A re-import therefore links the controls the workspace already has instead of copying them. 2. **The same library control.** If `libraryControlKey` names a library control the workspace has adopted, its oldest adoption is used. 3. **Otherwise it is created** from the document's content, marked imported (`source: imported`) and customer-owned (`licence_origin: customer_owned`), with no library link, because the text came from the file. Every resolution reads through the workspace's own access rules, so a forged id or workspace can select only controls the importer can already see. A control no imported risk names is not created; it is counted and reported. **Round trip.** Export a register, import it into an empty register in the same workspace, and export again: only keys, refs, ids and timestamps change. The importer's own tests prove this, a seeded property test among them. ## Projections ### CSV and Excel Both carry the same 23 columns, then one `Custom Field: ` column per risk field: | Columns | On import | |---|---| | Risk Ref | informational | | Title, Description, Category, Status, Treatment | read | | Inherent Likelihood, Inherent Impact · Residual Likelihood, Residual Impact | read | | Inherent Score, Inherent Level · Residual Score, Residual Level | derived; informational | | Review Date | read | | Risk Owner | for people: a member's name, or "Unresolved: Sam" | | Risk Owner Email | **the owner key**: a member's email, or an unresolved owner's text as given, **which can be a name** | | Linked Controls | control titles, for people | | Created At | informational | | Key | informational (the risk's key: the one a document save gave it, else its ref) | | Controls | control keys (refs), `; `-separated | | Sources | counted; not stored yet | | Treatment Decision | informational: the treatment as the app shows it, or "Not yet decided" until someone decides it. `Treatment` holds the stored strategy, which is what the import reads; an import never records a decision | The file also records the workspace that wrote it: a `# workspace: ` comment line in CSV, beside `# scoringMatrix: `, and a Workspace row on the Excel Summary sheet. **Losses compared with JSON:** - **Controls are keys, not content.** A ref names a control only in the workspace that wrote it, where the importer links it. Anywhere else, or in a file that doesn't say where it came from, keys are counted as unmatched: never guessed, never created. - Custom fields travel by column name, without their definitions (types, options). - Sources and rationale are not stored. - CSV cells that begin with a formula character are guarded with a leading apostrophe. The importer removes it for files it recognises as ours, and it recognises every shape our exports have produced since April 2026. ### Markdown (and any table in the document's vocabulary) The paste projection: a GitHub-flavoured Markdown table an assistant writes, saved as `.md`. The same table, tab-separated rows copied from it, or a CSV with these headers can also be pasted at riskregister.com/import, which shows what a save keeps before you sign in. The importer reads the **first** table in the file: - Outer pipes are optional, and `
` is a line break. - `\\`, `\|` and `\<` are a literal backslash, pipe and `<` (GFM's escapes), so `\
` is text. - Short rows are padded and extra cells dropped. - Emphasis around a header (`**Title**`) is ignored. Columns named with the document's own field names map automatically, by name and in any order: `Key` · `Title` · `Description` · `Category` · `Owner` · `Inherent likelihood` · `Inherent impact` · `Residual likelihood` · `Residual impact` · `Treatment` · `Status` · `Review date` · `Controls` · `Sources` The list may be followed by `Custom Field: ` columns. `Key` and `Title` must be present, and nothing outside the vocabulary. The same rule recognises a CSV or Excel sheet written with these headers. ```markdown | Key | Title | Category | Owner | Inherent likelihood | Inherent impact | Residual likelihood | Residual impact | Treatment | | --- | --- | --- | --- | :-: | :-: | :-: | :-: | --- | | phishing | Credential phishing | Technology | sam@example.com | 4 | 4 | 2 | 3 | mitigate | ``` **Losses:** everything the CSV projection loses, and a pasted table never says which workspace it came from. `Controls` keys are therefore always counted as unmatched, never linked. **Our Markdown export** (`GET /api/v1/registers/{id}/export?format=md`) writes this table, so it reads back: - A heading with the register's name, a line saying what the file is, then one table with every column above, then any `Custom Field: ` columns. - Nothing above the table can be read as a table. - `Key` is the risk's key as in the JSON. `Owner` is the owner key: a member's email, or an unresolved owner's text. `Controls` lists the linked controls' keys. `Sources` is empty. - Every value reads back as it was, apart from surrounding whitespace, and a line break of any kind comes back as a line feed. - A draft says so above the table. - The register's description and the controls' content travel in JSON, not here. ## Versions - **Import:** v1–v4 were flat JSON exports without a top-level `format`, and all four still import. - **Export:** only v5 is produced. - **Changes:** a change to v5 that invalidates existing documents gets a new `format` string. The schema is regenerated with every change to the importer, so it never lags the format it describes. --- Source: https://riskregister.com/docs/webhooks.md # Webhooks Outbound webhooks deliver event notifications from riskregister.com to your endpoint, usually within a minute of the change. Every event is signed with HMAC-SHA256 in a Stripe-compatible format, retried on failure, and protected against replay. ## Plan availability Webhooks require the Business plan (`webhooks` feature flag). Operator overrides via `org_feature_overrides` can enable for a specific org without a plan upgrade. ## Quick links | What you want | Where | | --- | --- | | Verify a webhook signature in your code | [Verifying signatures](https://riskregister.com/docs/webhook-signatures.md) | | The list of event types and their payloads | [Event types](https://riskregister.com/docs/webhook-event-types.md) | | Configure an endpoint in the app | `/{orgSlug}/settings/webhooks` (an admin, on the Business plan) | | List or read endpoints via the REST API | `GET /api/v1/webhook-endpoints` and `GET /api/v1/webhook-endpoints/{id}` (admin tokens). Endpoints are created, changed and deleted in the app | | Inspect delivery history | `GET /api/v1/webhook-endpoints/{id}/deliveries` | ## Architecture at a glance ``` ┌─────────────────┐ inside tx ┌──────────────────────┐ cron (60s) ┌──────────────────┐ │ Tenant request │ ─────────────> │ webhook_event_outbox │ ───────────────> │ Inngest worker │ │ (state change) │ audit + event │ (durable row) │ drains pending │ (HMAC sign + │ └─────────────────┘ └──────────────────────┘ │ POST + retry) │ └────────┬─────────┘ │ ↓ ┌──────────────────┐ │ Your endpoint │ │ verifies + 2xx │ └──────────────────┘ ``` Every state-mutating call that emits a webhook event writes a durable row to `webhook_event_outbox` **inside the same database transaction** as the audit row. If the transaction rolls back, no event fires. If it commits, the event is guaranteed to be in the outbox. A separate Inngest cron sweeps the outbox once a minute and hands pending events to the delivery worker. Once the hand-off succeeds, the row is marked `sent`; if it fails, its attempt count increments and a later sweep tries again, up to 20 times. The delivery worker then POSTs the event to each subscribed endpoint (see [Retry policy](#retry-policy)). This is the canonical "outbox pattern" for at-least-once delivery. It survives process death between the database commit and the network send. ## Headers on every delivery ``` POST /your-endpoint HTTP/1.1 Webhook-Id: 5dba8a3c-9b71-4f9b-9b9a-3b8b2a1c4d2e Webhook-Timestamp: 1748000000 Webhook-Signature: t=1748000000,v1=<64-char hex hmac> Content-Type: application/json { "id": "5dba8a3c-9b71-4f9b-9b9a-3b8b2a1c4d2e", "createdAt": "…", "event": "risk.created", "data": { "risk": { … } } } ``` - `Webhook-Id` — the event's `id`, stable across retries. It isn't signed, so dedupe on the `id` in the verified body, and reject a delivery whose header differs from it ([why](https://riskregister.com/docs/webhook-signatures.md#idempotency)). - `Webhook-Timestamp` — unix seconds. Reject anything older than ~5 minutes to defeat replay. - `Webhook-Signature` — `t=,v1=` Stripe-compatible. Verify before parsing. The signing scheme is HMAC-SHA256 over the literal string `.`. Use the raw request bytes, not the JSON-parsed payload. Full code samples in [Verifying signatures](https://riskregister.com/docs/webhook-signatures.md). ## Retry policy | Attempt | Delay before retry | | --- | --- | | 1 → 2 | ~5 seconds | | 2 → 3 | ~5 minutes | | 3 → 4 | ~30 minutes | | 4 → 5 | ~2 hours | | after 5 | the event is abandoned for that endpoint | An endpoint with 20 consecutive failed deliveries, and no success between them, is turned off so it doesn't keep racking up `webhook_deliveries` rows. It stays in your list with status `auto_disabled`; an admin re-enables it in the app after fixing the receiver, which resets the failure count. ## What counts as success? Any 2xx status. Your endpoint should: 1. Verify the signature. 2. Check the timestamp is recent (≤5 minutes). 3. Check the body's `id` equals `Webhook-Id`, then check that `id` against your dedup table (idempotent handling). 4. Process the event. 5. Return 2xx. If you return non-2xx, the worker retries. If you can't process the event (corrupted data, unknown event type), return 2xx anyway — your dedup table will keep the retry from delivering it again, and the failure is your problem to investigate from your own logs. Returning 4xx/5xx to a non-recoverable error just wastes retries. ## Endpoint constraints - HTTPS only. HTTP endpoints are rejected at create-time. The SSRF defence walks the resolved IP and refuses loopback, link-local, private, and unspecified ranges (in both dotted-quad and hex IPv6 form). DNS resolution is pinned per-delivery to defeat rebinding. - The `Host` header your endpoint sees is your customer-facing hostname. TLS SNI uses the same. The pinned IP path applies only to the connect step; cert verification is unchanged. - Response body is read up to 4 KiB and stored in `webhook_deliveries.response_body` for forensic display. Anything beyond 4 KiB is truncated. UTF-8 byte boundaries are respected. - Response timeout is 10 seconds. Slower than that and the worker treats it as a failure. - An organisation can hold at most 10 endpoints. Paused and auto-disabled endpoints count; revoking one frees its place. Point several systems at one receiver if you need more. - At most two of an organisation's events are being delivered at once. The rest wait their turn, so slow receivers delay only their own organisation's deliveries, never another's. ## Secret management Each endpoint has a secret used to compute the HMAC. The secret format is `rrws_<43 base64url chars>`. **One-shot reveal.** The secret is shown once at endpoint creation. Copy it to your secret store immediately. **Rotation.** An admin rotates the secret from the endpoint's page in the app. The new secret is shown once. For the next 24 hours every delivery is signed with both the old and the new secret (two `v1=` values in the signature header), so you can move your receiver to the new secret without dropping events; after that, only the new one signs. See [Verifying signatures](https://riskregister.com/docs/webhook-signatures.md). **At rest.** Server stores secrets encrypted with AES-256-GCM under a per-deployment key. Plaintext is never persisted. ## Event selection When an admin creates an endpoint in the app, they choose which event types it receives (for example `risk.created`, `risk.escalated` and `review.overdue`), and can change the selection later on the endpoint's page. Subscribing to a subset reduces noise and reduces the audit surface if your endpoint is ever compromised. Subscribe to everything during integration testing, then narrow once you know which events you actually consume. ## Testing Each endpoint has a "Send test event" button in the management UI that fires a synthetic event bypassing the outbox. The signature, headers, and body shape all match production — useful for verifying your receiver before any live event lands. For local development, [smee.io](https://smee.io/) or [ngrok](https://ngrok.com/) work fine. Just remember the SSRF defence rejects loopback IPs — you need a public URL. ## See also - [Verifying signatures](https://riskregister.com/docs/webhook-signatures.md) — code samples for Node.js and Python. - [Event types](https://riskregister.com/docs/webhook-event-types.md) — the catalog of every event you can subscribe to. - ADR-007 Domain events via the webhook outbox — the design. - [API errors](https://riskregister.com/docs/errors.md) — for the management endpoints. - [Rate limits](https://riskregister.com/docs/rate-limits.md) — per-org outbox dispatch budget. --- Source: https://riskregister.com/docs/webhook-event-types.md # Event types Every webhook delivery carries a `type` field on the JSON body and the same string is one of the enum values listed below. Subscribe to the events you care about when you configure your endpoint. ## Envelope Every event shares the same outer shape: ```json { "type": "risk.created", "id": "5dba8a3c-9b71-4f9b-9b9a-3b8b2a1c4d2e", "createdAt": "2026-05-28T12:00:00.000Z", "orgId": "", "data": { // event-specific payload — see below } } ``` - `type` — the event type, one of the strings in the table below. - `id` — UUID for this event. Stable across retries; same as the `Webhook-Id` header. Use this to dedupe. - `createdAt` — when the event was created (the moment the outbox row was written, not when it was dispatched). - `orgId` — the organisation that triggered the event. The receiver knows which tenant this is. - `data` — event-specific payload. DTOs in `data` follow the public-API shape (`RiskDtoV1`, `ControlDtoV1`, etc.). Same projection that `/api/v1/*` GET responses use. See [`docs/api/openapi.yaml`](https://riskregister.com/docs/api/openapi.yaml) for the full DTO schemas. ## Event catalog | Type | Trigger | Payload | | --- | --- | --- | | `register.created` | A new register was created (UI, API, clone, framework instantiation, AI generation). | `{ register: RegisterDtoV1 }` | | `register.archived` | A register moved into `archived` status, from any surface that can archive one: the archive endpoint (`POST /api/v1/registers/{id}/archive`), the session-authenticated `DELETE /api/registers/{id}`, or a `PATCH` that sets `status: archived` (the app's Edit page, through `PATCH /api/registers/{id}`, or `PATCH /api/v1/registers/{id}`). Transition-only: re-archiving an already archived register sends nothing, and un-archiving (moving it back to active) sends no event. | `{ register: RegisterDtoV1, previous: RegisterDtoV1 }` | | `risk.created` | A new risk was added to a register. Fires on single creates AND on every risk in a bulk create (AI generation, framework instantiation, import). | `{ risk: RiskDtoV1 }` | | `risk.updated` | A risk's fields or custom field values changed via PATCH or a bulk edit. Fires only when something changed: a request that resends a risk's current values, or a bulk edit over risks that already have the value, fires nothing for those risks. Doesn't fire for verb endpoints (those have their own events). | `{ risk: RiskDtoV1, previous: RiskDtoV1 }` | | `risk.deleted` | A risk was deleted. Payload captures the final state before deletion. The risk's treatment actions, reviews and acceptances (and their evidence links), its control links, comments and custom field values are removed with it; this event is the only one sent for them (no `treatment_action.deleted`, `review.*` or other per-item event fires for what goes with a deleted risk). | `{ risk: RiskDtoV1 }` | | `risk.escalated` | Sub-event of `risk.updated` and `risk.assessed`. Fires when the residual band crosses upward (e.g. low → medium). Useful for paging on degraded posture. | `{ risk: RiskDtoV1, previous: RiskDtoV1, previousLevel: ScoreBand, currentLevel: ScoreBand }` | | `risk.de_escalated` | Mirror of `risk.escalated` for downward band transitions. Useful for "good news" workflows. | `{ risk: RiskDtoV1, previous: RiskDtoV1, previousLevel: ScoreBand, currentLevel: ScoreBand }` | | `risk.appetite_breached` | Sub-event of `risk.updated` and `risk.assessed`. Fires on the transition (non-breach → breach) when a re-score pushes a risk's residual above its register's configured appetite threshold. Transition-only: a risk already in breach that is re-scored and stays in breach does NOT re-fire. Not emitted on create, nor when an operator moves the threshold under a static risk. | `{ risk: RiskDtoV1, previous: RiskDtoV1, appetiteThreshold: number, residualScore: number | null }` | | `risk.assessed` | Fires on `POST /api/v1/risks/{id}/assess`. Captures the full assessment with operator-supplied rationale as a first-class field. | `{ risk: RiskDtoV1, previous: RiskDtoV1, scoresBefore: {...}, scoresAfter: {...}, rationale: string }` | | `risk.treated` | Fires on `POST /api/v1/risks/{id}/treat`. Captures treatment + status transitions with rationale. | `{ risk: RiskDtoV1, previous: RiskDtoV1, treatmentBefore: string, treatmentAfter: string, statusBefore: string, statusAfter: string, rationale: string }` | | `control.status_changed` | A control's effectiveness aggregate changed (effectively / partially / not implemented). | `{ control: ControlDtoV1, previous: ControlDtoV1, statusBefore: string, statusAfter: string }` | | `control.deleted` | A control was deleted. | `{ control: ControlDtoV1 }` | | `review.requested` | A review was requested on a risk. | `{ review: RiskReviewDtoV1, risk: RiskDtoV1 }` | | `review.completed` | A review was completed: a pending one, or one recorded in one step with no request first (then `previous` is null). The reviewer's findings are `review.notes`, and the links to its evidence are `review.evidence`. | `{ review: RiskReviewDtoV1, previous: RiskReviewDtoV1 \| null, risk: RiskDtoV1 }` | | `review.cancelled` | A pending review was cancelled with operator-supplied reason. | `{ review: RiskReviewDtoV1, previous: RiskReviewDtoV1, risk: RiskDtoV1, reason: string }` | | `treatment_action.created` | An item was added to a risk's treatment plan. | `{ risk: RiskDtoV1, action: TreatmentActionDtoV1 }` | | `treatment_action.updated` | A plan item's fields changed (title, description, owner, due date, notes), or an evidence link was added to it or removed (`action.evidence` and `previous.evidence` show which) — **not** its status. | `{ risk: RiskDtoV1, action: TreatmentActionDtoV1, previous: TreatmentActionDtoV1 }` | | `treatment_action.status_changed` | A plan item moved between `open` / `in_progress` / `done` / `cancelled`, including a reopen. `reason` is present on a cancel, null otherwise. A same-status move fires nothing. | `{ risk: RiskDtoV1, action: TreatmentActionDtoV1, previous: TreatmentActionDtoV1, reason: string \| null }` | | `treatment_action.deleted` | A plan item was hard-deleted on its own (an item that should never have existed, as distinct from cancelling abandoned work). Not sent for the items removed when their risk is deleted: `risk.deleted` covers those. | `{ risk: RiskDtoV1, action: TreatmentActionDtoV1 }` | | `review.overdue` | Cron-fired by the daily reminder run: seven days before a risk's review date, on the day, and once it has passed without a completed review. `reminderType` says which. Fired at most once per risk, reminder type and review date; a new review date starts again. Only for risks in an active register: an archived or closed register's risks send neither this nor the reminder email *(v0.49.819.0+)*. | `{ risk: RiskDtoV1, reminderType: 'review_reminder_7d' \| 'review_reminder_due' \| 'review_reminder_overdue' }` | | `invitation.created` | An org admin invited someone by email. Carries no credential material: the token itself is never sent. | `{ invitation: InvitationDtoV1 }` | | `invitation.accepted` | The person invited accepted and joined the organisation (`acceptedAt` and `acceptedByUserId` set). Subscribe to this for "someone joined". A revoked invitation sends nothing. | `{ invitation: InvitationDtoV1 }` | | `organisation.updated` | An org admin changed the organisation's name, sector, size band or region in Settings. Billing and plan changes are not part of it. | `{ organisation: OrganisationDtoV1, previous: OrganisationDtoV1 }` | | `user.updated` | A person changed their own profile (their display name). An admin changing someone's role sends no webhook. | `{ user: UserProfileDtoV1, previous: UserProfileDtoV1 }` | The canonical source list lives at `src/lib/webhooks/events.ts` and the database enum is `webhook_event_type` in `src/db/schema/enums.ts`. ## On `previous` Stateful transitions (`updated`, `archived`, `assessed`, `treated`, `escalated`, `de_escalated`, `status_changed`, `completed`, `cancelled`) carry a `previous` object alongside the current state. Subscribers can compute the delta inline without a follow-up `/api/v1/*` GET. Creation events (`created`, `requested`) don't carry `previous` (nothing was there before). Deletion events (`deleted`) carry the final state as the main payload (the row is gone after this event fires). ## Sub-event semantics — escalation When a risk's residual band transitions upward, **both** `risk.updated` and `risk.escalated` fire. Subscribers wanting just the upward transitions subscribe to `risk.escalated` alone. Subscribers wanting all changes subscribe to `risk.updated` and may filter on `previous.residualScore` themselves. Same logic mirrored for `risk.de_escalated` on downward transitions. This is deliberate. The sub-event pattern lets pager-worthy subscribers (Slack alerts, on-call paging) opt into the narrow surface without parsing every update. ## Rationale on verb events `risk.assessed` and `risk.treated` carry the operator's free-text rationale, and `review.cancelled` its reason, as a first-class field on `data`, not buried in audit-log diff archaeology. A completed review's findings are its `notes`. The assess and treat endpoints require a rationale, and cancelling a review a reason (1–5000 chars, server-validated). Audit + webhook + activity feed all read it from the same source. ## Bulk creation events Bulk paths (AI generation, framework template instantiation, register clone, CSV/Excel/JSON import) emit one `risk.created` event per risk created, atomic with the audit row. A register an assistant saves or generates as a draft through the API (`POST /api/v1/registers/from-document` and `/from-template`; `/from-context` with a `propose` token or app) sends nothing until a person adopts it, and adoption sends `register.created` and one `risk.created` per risk. A 1000-row import produces 1000 outbox rows; the dispatch cron drains them at the per-org rate cap (1000 events/min/org), so a single bulk operation can take ~1 minute end-to-end at steady state. The outbox insert chunks at 500 rows per statement to stay well under the Postgres bind-parameter ceiling. Subscribers see events arrive in batches as the cron sweeps. ## Order of delivery Events from the same `withTenant` transaction land in the outbox in the order they were emitted; the cron preserves that order on dispatch. **Across transactions, no order guarantee.** Two simultaneous `POST /api/v1/registers/{id}/risks` calls produce two events whose order at your endpoint is undefined. If you care about order, dedupe on the event's `id` and sort by `createdAt` (or your own arrival timestamp) on the receiver. ## Treatment plans — why there is no "plan complete" event The four `treatment_action.*` events are enough to mirror a plan into a task tracker: create files a ticket, `updated` keeps it in sync, `status_changed` moves it, `deleted` closes it. Every one carries its parent `risk`, so a subscriber never needs a follow-up fetch to know what to file against. There is deliberately **no** `treatment_action.plan_completed`, even though it would be the single most useful signal for an integration. It is a *derived claim*, and the question we ask of any derived claim is what it reads on a plan with one item, created five seconds ago and immediately ticked. The answer is "complete" — which says nothing about whether the risk was actually treated. Subscribers who want that signal can compute it from these four events plus `GET /api/v1/risks/{riskId}/treatment-actions`, and computing it themselves means they decide what "complete" means for their organisation. We are not better placed to decide that than they are. ## See also - [Verifying signatures](https://riskregister.com/docs/webhook-signatures.md) — verify before parsing. - [Webhook integration overview](https://riskregister.com/docs/webhooks.md) — architecture, retries, secrets. - ADR-007 Domain events. --- Source: https://riskregister.com/docs/webhook-signatures.md # Verifying webhook signatures riskregister.com signs every outbound webhook with HMAC-SHA256 over the **raw request body** plus a unix timestamp. Verifying the signature on every received webhook is the only way to be sure the event came from us and wasn't tampered with in transit. ## Headers Every webhook delivery carries three headers: | Header | Value | Purpose | |---------------------|----------------------------------------------------------|------------------------------------------------------------------| | `Webhook-Id` | UUID, e.g. `b1c2d3e4-…` | The event's `id`, stable across retries. Not signed: dedupe on the body's `id` (see [Idempotency](#idempotency)) | | `Webhook-Timestamp` | Unix epoch seconds, e.g. `1748000000` | Replay defence — reject events older than ~5 minutes | | `Webhook-Signature` | `t=,v1=` (may carry a second `v1=` during rotation) | HMAC-SHA256 over `.` with your secret | > **During a secret rotation** the header carries **two** `v1=` signatures > — one for the new secret and one for the previous secret, for a 24-hour > grace window (see [Rotating your signing secret](#rotating-your-signing-secret)). > Your verifier **must accept a match on ANY `v1=` value**, not just the > first or last. The examples below do this. A verifier that parses the > header into a single `v1` (e.g. `Object.fromEntries`) will reject > deliveries during a rotation — iterate every `v1=` instead. ## What gets signed The signed payload is **the literal string `.`** — the timestamp from the header, a literal `.` character, then the exact bytes of the request body. No JSON canonicalisation, no whitespace stripping. Use the raw body bytes from your HTTP framework before any JSON parsing. ## Verification (Node.js) ```js import crypto from 'node:crypto'; function verifyWebhook(secret, headers, rawBody) { const signatureHeader = headers['webhook-signature']; const timestamp = headers['webhook-timestamp']; if (!signatureHeader || !timestamp) { throw new Error('missing webhook headers'); } // Replay defence — reject events older than 5 minutes. const ageSeconds = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10); if (Math.abs(ageSeconds) > 300) { throw new Error('webhook timestamp outside tolerance window'); } // Parse `t=,v1=[,v1=]`. There may be MORE THAN ONE // `v1=` — during a 24h secret-rotation window we sign with both the // new and the previous secret, so collect EVERY `v1=` value and // accept a match on any of them. (Don't use `Object.fromEntries`: it // keeps only the last `v1`, which breaks verification mid-rotation.) const pairs = signatureHeader.split(',').map((p) => p.split('=')); const t = pairs.find(([k]) => k === 't')?.[1]; const providedSigs = pairs.filter(([k]) => k === 'v1').map(([, v]) => v); if (t !== timestamp) { throw new Error('signature timestamp mismatch'); } if (providedSigs.length === 0) { throw new Error('signature header missing v1 value'); } // Recompute HMAC-SHA256 over `.` with YOUR secret. const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`, 'utf-8') .digest('hex'); const expectedBuf = Buffer.from(expected, 'hex'); // Constant-time compare against each candidate — using `===` would // leak timing information to an attacker who can probe the endpoint. const ok = providedSigs.some((provided) => { const a = Buffer.from(provided, 'hex'); return ( a.length === expectedBuf.length && crypto.timingSafeEqual(a, expectedBuf) ); }); if (!ok) { throw new Error('invalid signature'); } } ``` ## Verification (Python) ```python import hmac import hashlib import time def verify_webhook(secret: str, headers: dict, raw_body: bytes): signature_header = headers["webhook-signature"] timestamp = headers["webhook-timestamp"] age = int(time.time()) - int(timestamp) if abs(age) > 300: raise ValueError("webhook timestamp outside tolerance window") # Collect EVERY `v1=` value — the header carries two during a 24h # secret-rotation window (one per live secret). Don't build a dict # (it keeps only the last v1 and breaks verification mid-rotation). pairs = [p.split("=", 1) for p in signature_header.split(",")] t = next((v for k, v in pairs if k == "t"), None) provided_sigs = [v for k, v in pairs if k == "v1"] # The signature header echoes the same timestamp it signed — # mismatch here means an upstream proxy rewrote one but not # the other. Reject before the HMAC compare so a malicious # rewrite of `Webhook-Timestamp` alone (without the secret) is # caught explicitly rather than as a generic signature failure. if t != timestamp: raise ValueError("signature timestamp mismatch") if not provided_sigs: raise ValueError("signature header missing v1 value") expected = hmac.new( secret.encode("utf-8"), f"{timestamp}.".encode("utf-8") + raw_body, hashlib.sha256, ).hexdigest() # Accept a constant-time match against ANY of the candidate signatures. if not any(hmac.compare_digest(provided, expected) for provided in provided_sigs): raise ValueError("invalid signature") ``` ## Idempotency Dedupe on the event's `id` **from the verified body**. Every retry of an event carries the same `id`, so deduping on it gives you exactly-once handling however many delivery attempts there were. Don't dedupe on the `Webhook-Id` header alone. The signature covers the timestamp and the body, not the headers: someone who captures a delivery can replay it inside your timestamp window under a different `Webhook-Id`, and the signature still verifies. They can't change the body's `id` without breaking it. So verify first, take the id from the body, and reject a delivery whose `Webhook-Id` differs from it. A genuine delivery never does: the header carries the body's `id`. ```js async function handleWebhook(req, res) { // Verify before trusting anything else in the request. verifyWebhook(secret, req.headers, req.rawBody); const event = JSON.parse(req.rawBody); // The header isn't signed; the body's id is. A genuine delivery // carries the same value in both. if (req.headers['webhook-id'] !== event.id) { return res.sendStatus(400); } // Reject duplicates without re-running side effects. if (await alreadyHandled(event.id)) { return res.sendStatus(200); } await processEvent(event); await markHandled(event.id); res.sendStatus(200); } ``` ## Retries and auto-disable We try a delivery up to **5 times**, waiting about 5 seconds, 5 minutes, 30 minutes and 2 hours between attempts. After the fifth failed attempt we abandon the event for that endpoint. If your endpoint has **20 consecutive failed deliveries** with no success between them, we **auto-disable** it. The endpoint stays in your dashboard but stops receiving events; an admin must explicitly re-enable it. Auto-disable is logged as a `webhook_endpoint.auto_disabled` audit row visible via the public audit-log API. We treat any HTTP 2xx response as a success. Anything else (3xx, 4xx, 5xx, network error, timeout) counts as a failure and consumes a retry slot. **Endpoints have a 10-second response timeout.** Acknowledge quickly — return 200 the moment you've persisted the event id; process the event asynchronously. ## Rotating your signing secret Rotation has a **24-hour overlap window**. When you mint a new secret, your previous secret keeps working too: for 24 hours every delivery is signed with **both** secrets (two `v1=` values in `Webhook-Signature`), so — as long as your verifier accepts a match on any `v1=` (the examples above do) — nothing breaks while you migrate. After 24 hours only the new secret's signature is emitted. 1. Mint the new secret in the riskregister.com dashboard. The plaintext is shown to you exactly once — copy it immediately. 2. Update your endpoint to verify with the new secret, any time within the 24-hour window. Deliveries keep verifying throughout, whether your endpoint still holds the old secret or the new one. 3. Test by sending a test event from the dashboard — confirm your endpoint accepts it. Notes: - Only the **most recent** previous secret stays live. If you rotate a **second** time before updating your endpoint, the window resets to the latest pair and the earliest secret is retired — so update your endpoint before rotating again. - A verifier that reads only a single `v1` (e.g. via `Object.fromEntries` or `dict(...)`) will reject deliveries during a rotation. Iterate every `v1=` value, as the examples above do. ## Event types Some of the event types (the full list, with payloads, is in [Event types](https://riskregister.com/docs/webhook-event-types.md)): - `risk.created` — fired when a new risk is added. - `risk.updated` — fired on any field change to a risk. - `risk.deleted` *(v0.40.0.0+)* — fired BEFORE a risk is deleted; payload carries the final-state snapshot so you can reconcile your mirror without a follow-up GET (which would 404). - `risk.escalated` — fired (in addition to `risk.updated`) when a risk's residual band moves UP (e.g. medium → high). - `risk.de_escalated` *(v0.40.0.0+)* — mirror of `risk.escalated` for DOWNWARD transitions. Separate event so subscribers can opt into upward-only (often the only direction pager-worthy) without filtering payloads in their handler. - `risk.assessed` *(v0.44.0.0+)* — fired when an operator records a fresh assessment via `POST /api/v1/risks/{id}/assess`. Captures the rationale as a first-class payload field. - `risk.treated` *(v0.44.0.0+)* — fired when an operator records a treatment decision via `POST /api/v1/risks/{id}/treat`. Captures the rationale as a first-class payload field. - `control.status_changed` — fired on any control update. - `control.deleted` *(v0.40.0.0+)* — fired BEFORE a control is deleted; payload carries the final-state snapshot. - `register.created` *(v0.40.0.0+)* — fired on every register creation surface (UI, public REST API, clone, AI generation, framework instantiation). Clone/template/AI paths additionally fire `risk.created` per spawned child risk. - `register.archived` *(v0.40.0.0+)* — fired on the actual transition to `archived` status, whether through the archive endpoint or a `PATCH` that sets `status: archived` (UI Edit page or public REST API); idempotent on re-archive (no event on a no-op). Un-archiving sends no event. - `review.requested` *(v0.45.0.0+)* — fired when an operator opens a review request via `POST /api/v1/risks/{id}/request-review`. - `review.completed` *(v0.45.0.0+)* — fired when a pending review is closed via `POST /api/v1/risks/{id}/complete-review`. Always advances the risk's `lastReviewedAt` (a record of when it was last reviewed). The reminder cron is driven by `reviewDate`, not `lastReviewedAt`, so completing does NOT by itself reschedule reminders; supply the optional `nextReviewDate` *(v0.49.99.0+)* on the request to roll `reviewDate` forward and clear the overdue state in the same call. The review's `evidence` carries the links given with it *(v0.49.703.0+)*. - `review.cancelled` *(v0.45.0.0+)* — fired when a pending review is cancelled via `POST /api/v1/risks/{id}/cancel-review`. The operator's cancel reason rides as a first-class payload field. - `review.overdue` — fired by the daily reminder cron when a risk's review date has passed (also when 7 days out and on the due date itself; the `data.reminderType` field discriminates). Each fires at most once per risk, reminder type and review date, and only for a risk in an active register *(archived and closed registers send none since v0.49.819.0)*. **Customers MUST tolerate unknown event types**: future event additions are non-breaking and your handler should silently skip events it doesn't recognise, not 500 on them. ## Payload shape Every event has the same envelope: ```json { "id": "", "createdAt": "2026-05-09T12:34:56.789Z", "event": "risk.created", "data": { "risk": { /* RiskDtoV1 from the public REST API */ } } } ``` `data` shape varies by event type: | Event | Payload | |---------------------------|-----------------------------------------------------------------------------------------------| | `risk.created` | `{ risk: RiskDto }` | | `risk.updated` | `{ risk: RiskDto, previous: RiskDto }` | | `risk.deleted` | `{ risk: RiskDto }` — final-state snapshot | | `risk.escalated` | `{ risk: RiskDto, previous: RiskDto, previousLevel: 'low|medium|high|critical', currentLevel: ... }` | | `risk.de_escalated` | `{ risk: RiskDto, previous: RiskDto, previousLevel: ..., currentLevel: ... }` (mirror of escalated) | | `risk.assessed` | `{ risk: RiskDto, previous: RiskDto, rationale: string, scoreBefore: {...}, scoreAfter: {...} }` | | `risk.treated` | `{ risk: RiskDto, previous: RiskDto, rationale: string, treatmentBefore: {...}, treatmentAfter: {...} }` | | `control.status_changed` | `{ control: ControlDto, previous: ControlDto }` | | `control.deleted` | `{ control: ControlDto }` — final-state snapshot | | `register.created` | `{ register: RegisterDto }` | | `register.archived` | `{ register: RegisterDto, previous: RegisterDto }` | | `review.requested` | `{ review: RiskReviewDto, risk: RiskDto }` | | `review.completed` | `{ review: RiskReviewDto, previous: RiskReviewDto, risk: RiskDto }` | | `review.cancelled` | `{ review: RiskReviewDto, previous: RiskReviewDto, risk: RiskDto, reason: string }` | | `review.overdue` | `{ risk: RiskDto, reminderType: 'review_reminder_7d|review_reminder_due|review_reminder_overdue' }` | The DTO shapes are documented in the [public REST API OpenAPI spec](https://riskregister.com/docs/api/openapi.yaml) — the webhook contract uses the same DTOs as the corresponding `GET /api/v1/risks/:id` and `GET /api/v1/controls/:id` endpoints, so any client SDK generated from the OpenAPI spec works for webhook payloads too.