REST API reference
The Keel REST API gives programmatic access to your workspace. Every request is scoped to the organization the API key belongs to, so the API never exposes more than the key already can.
The always-current, machine-readable definition lives at
/api/v1/openapi.json on your workspace origin, and the in-app Integrations → API docs page
renders it interactively. This page is a stable, human-readable overview of the same surface.
Base URL
Section titled “Base URL”https://app.keelgrc.comAll paths below are relative to that origin (for example https://app.keelgrc.com/api/v1/me).
Authentication
Section titled “Authentication”Create an API key in the app under Integrations (it is shown once, so store it securely). Send it on every request, using either header:
# Bearer tokencurl https://app.keelgrc.com/api/v1/me \ -H "Authorization: Bearer $KEEL_API_KEY"
# or the x-api-key headercurl https://app.keelgrc.com/api/v1/me \ -H "x-api-key: $KEEL_API_KEY"Keys are scoped to a single workspace (organization). Treat a key like a password; rotate it if it is exposed.
Endpoints
Section titled “Endpoints”| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/openapi.json |
The OpenAPI 3.0 description of this API (no authentication; cached 300s) |
| GET | /api/v1/me |
Connection test and the authenticated organization identity |
| GET | /api/v1/members |
List the workspace’s members (people with a Keel seat), read-only |
| GET | /api/v1/controls |
List controls with status and owner (filter with ?query=) |
| POST | /api/v1/controls |
Create one control (owner/admin) |
| GET | /api/v1/controls/{id} |
One control, with the clauses it is mapped to |
| PATCH | /api/v1/controls/{id} |
Edit a control. Renaming or reassigning needs owner/admin; changing state only needs a non-auditor |
| DELETE | /api/v1/controls/{id} |
Delete a control (owner/admin) |
| GET | /api/v1/controls/{id}/crosswalks |
The framework clauses a control is mapped to, across all frameworks |
| POST | /api/v1/controls/{id}/crosswalks |
Map a control to one framework clause (owner/admin) |
| DELETE | /api/v1/controls/{id}/crosswalks |
Unmap a control from one clause, named in the query string (owner/admin) |
| GET | /api/v1/readiness |
ISO 27001 readiness summary (counts and percentage) |
| GET | /api/v1/frameworks |
Every framework the workspace has applied, each with its readiness |
| POST | /api/v1/frameworks/{key}/starter-controls |
Add a framework’s recommended controls, already mapped to its clauses (owner/admin) |
| GET | /api/v1/tasks |
List open and completed tasks (filter by title with ?query=) |
| POST | /api/v1/tasks |
Create a task (any non-auditor) |
| GET | /api/v1/tasks/{id} |
One task |
| PATCH | /api/v1/tasks/{id} |
Change a task’s status, assignee, or both |
| GET | /api/v1/evidence |
List evidence, newest first (filter with ?since=) |
| POST | /api/v1/evidence |
Report evidence as a link (JSON) or a file (multipart) (any non-auditor) |
| GET | /api/v1/evidence/{id} |
One evidence record |
| PATCH | /api/v1/evidence/{id} |
Edit expiry or owner, and link or unlink controls |
| DELETE | /api/v1/evidence/{id} |
Delete an evidence record |
| GET | /api/v1/risks |
List risks, sorted by status, then level, then score (see below) |
| POST | /api/v1/risks |
Create a risk (any non-auditor) |
| GET | /api/v1/risks/{id} |
One risk |
| PATCH | /api/v1/risks/{id} |
Edit a risk |
| DELETE | /api/v1/risks/{id} |
Delete a risk (owner/admin) |
| GET | /api/v1/vendors |
List vendors (filter with ?query=) |
| POST | /api/v1/vendors |
Create a vendor (any non-auditor) |
| GET | /api/v1/vendors/{id} |
One vendor |
| PATCH | /api/v1/vendors/{id} |
Edit a vendor |
| DELETE | /api/v1/vendors/{id} |
Delete a vendor |
| GET | /api/v1/people |
List directory people (filter with ?query=) |
| POST | /api/v1/people |
Upsert a person by email, idempotent (owner/admin) |
| GET | /api/v1/people/{id} |
One person |
| PATCH | /api/v1/people/{id} |
Edit a person (owner/admin). Refuses a connector-synced person |
| DELETE | /api/v1/people/{id} |
Delete a person (owner/admin). Refuses a connector-synced person |
| GET | /api/v1/policies |
List policies (filter with ?query=) |
| POST | /api/v1/policies |
Create a policy, idempotent on key (any non-auditor) |
| GET | /api/v1/policies/{id} |
One policy, including its markdown body |
| PATCH | /api/v1/policies/{id} |
Edit a policy. Records a revision, as saving in the app does |
| DELETE | /api/v1/policies/{id} |
Permanently delete a policy and its history (owner/admin). No undo |
| GET | /api/v1/hooks |
List webhook subscriptions |
| POST | /api/v1/hooks |
Subscribe to events and get the signing secret, shown once (owner/admin) |
| DELETE | /api/v1/hooks/{id} |
Unsubscribe a webhook (owner/admin) |
| POST | /api/v1/hooks/{id}/rotate |
Replace a subscription’s signing secret (owner/admin) |
Risk list ordering
Section titled “Risk list ordering”GET /api/v1/risks sorts by status first, then level, then score:
- Status:
open, thentreating, thenaccepted, thenclosed. - Level within a status: unscored risks (
levelisnull) first, thenhigh, thenmedium, thenlow. - Score within a level: residual score descending, falling back to the inherent score when no residual score is set.
Status outranks severity, so an open low risk sorts above a closed high one. If you want
strict severity order, sort client-side on the returned level and score rather than relying on
the response order.
Unscored risks
Section titled “Unscored risks”A risk can exist without a score. On a risk read, likelihood, impact, inherentScore and
level are null when the risk has not been rated, and the app shows it as Needs scoring. This
is how a risk arrives when it is imported from another platform with no likelihood or impact: Keel
leaves it unscored rather than inventing a middle score. inherentScore is set only once both
likelihood and impact are, and level stays null until an inherent or residual score exists.
On POST /api/v1/risks, likelihood and impact are optional. Omit them, or send null, to
create an unscored risk. On PATCH /api/v1/risks/{id}, sending either one as null clears it.
Request rules
Section titled “Request rules”Undocumented fields are refused
Section titled “Undocumented fields are refused”The write endpoints for evidence, tasks, policies, controls, risks, vendors, people and webhooks
check every field in the request body. A field the endpoint does not accept is a 400, nothing is written, and the
response names the fields it refused:
{ "error": "bad_request", "message": "unknown field(s): startDate. Accepted fields: title, description, dueAt, assigneeEmail. startDate: tasks have no start date in Keel. Send dueAt for the due date.", "unknownFields": ["startDate"]}Where callers commonly send a field that does not exist, the message says why. Read the
message rather than retrying the same body.
Members
Section titled “Members”GET /api/v1/members returns { "members": [...] }, each with id, email, name and role
(owner, admin, member or auditor). These ids are what ownerMembershipId on a policy,
ownerId on evidence and assigneeId on a task refer to. It is not the HR directory:
GET /api/v1/people lists employees, including people with no Keel seat.
Fields that name a person by email (assigneeEmail on tasks, ownerEmail on policies) must
match one of these members, compared case-insensitively. An address that matches no member is a
400 and nothing is written.
businessOwnerEmail and securityOwnerEmail on PATCH /api/v1/vendors/{id} must also match a
member. Send null or "" to clear one; omit it to leave it. Creating a vendor takes no owners,
so create it and then set them with PATCH. Changing an owner sends no webhook event.
POST /api/v1/tasksacceptstitle(required),description,dueAtandassigneeEmail.dueAtmust be an ISO 8601 date or date-time. A value that does not parse is a400; an empty string ornullmeans no due date.PATCH /api/v1/tasks/{id}acceptsstatusandassigneeEmail. SendassigneeEmailasnullor""to unassign.- Title, description and due date cannot be changed after a task is created, in the app or the
API, so sending them on
PATCHis a400. - Tasks have no start date, so
startDateis refused on both.
Assigning through the API fires the task.assigned webhook event. It does not send the
assignment email the app sends.
Evidence and controls
Section titled “Evidence and controls”Evidence reads (GET /api/v1/evidence and GET /api/v1/evidence/{id}) include controls, the
controls the item is linked to, as [{ "id": "...", "key": "..." }].
- On
POST /api/v1/evidence,controlIdandcontrolKeyattach the new item to a control. - On
PATCH /api/v1/evidence/{id},linkControlsandunlinkControlseach take an array of control ids or keys. Linking a control that is already linked, or unlinking one that is not, changes nothing. - Every control you name must exist in your workspace. If any one does not, the request is a
400and nothing is created or changed.
Evidence cannot be attached to a vendor through the API, so vendorId is refused.
Policies
Section titled “Policies”PATCH /api/v1/policies/{id} accepts ownerEmail to set the policy owner by email, or null to
clear it. Send ownerEmail or ownerMembershipId, not both. Creating a policy takes no owner,
status or dates, so create it first and then set those with PATCH.
Controls and clause mappings
Section titled “Controls and clause mappings”A clause counts toward readiness only once a control is mapped to it, and is covered only when
an implemented control on it has unexpired evidence or an approved policy linked. A clause whose
implemented controls have neither is counted in unverified on the readiness and frameworks
responses. The API can create a
control, add a framework’s recommended controls, and map or unmap any control. It cannot apply a
framework; do that in the app under Frameworks. The calls below that create, add, map or unmap
need the owner or admin role. Reading a control or listing its mappings does not.
POST /api/v1/controls does what Controls → Add a control does in the app. It takes name
(required), description and key, and answers 201 with the new control.
keyis your own identifier for the control, such asAC-1, and must be unique in the workspace. Omit it, or send""ornull, and Keel generates one from the name. A key longer than 60 characters, or one shaped like a uuid, is a400. So is a key containing a control or invisible formatting character (Unicode categories Cc, Cf, Zl and Zp), such as a line break, a zero-width space or a bidi override.- A key the workspace already uses is a
409witherrorset tocontrol_key_exists. The response carries the existing control, and nothing is overwritten. - The new control starts at
not_startedwith no owner and no mappings. Sendingstate,ownerEmailor any mapping field on the create is a400: set the state or owner withPATCH /api/v1/controls/{id}, then map clauses with the crosswalks call below. - A key that matches one of Keel’s recommended controls is treated as that control by a later starter-controls call, which keeps your name and adds the framework’s mappings to it.
GET /api/v1/controls/{id} includes crosswalks, the same list the crosswalks endpoint below
returns. The list endpoint does not carry it. The create response has the same shape, with
crosswalks empty.
POST /api/v1/frameworks/{key}/starter-controls does what Controls → Add recommended controls
does, for one framework. It adds each recommended control the workspace does not have yet and maps
all of them to that framework’s clauses. A control the workspace already has keeps its name,
description and state, and only gains the mappings. It takes no body fields, and a second call adds
nothing. The response counts what the call changed: controlsAdded, controlsPresent,
mappingsAdded and mappingsSkipped (normally 0).
- A framework the workspace has not applied is a
409witherrorset toframework_not_applied. - A key Keel does not ship, or a framework with no recommended controls, is a
404.
GET /api/v1/controls/{id}/crosswalks lists every clause the control is mapped to, as
frameworkKey, requirementRef and requirementTitle, including clauses of frameworks the
workspace has not applied.
POST /api/v1/controls/{id}/crosswalks maps the control to one clause. It takes frameworkKey (as
GET /api/v1/frameworks lists it) and requirementRef, written exactly as Keel writes it (for
example 4.1).
- The framework must be applied in the workspace, or the call is a
409withframework_not_applied. - The ref must be a clause Keel authors for that framework. A section heading is refused with a
400, because readiness never scores one; map the control to a clause under it instead. - A new mapping answers
201. Mapping a clause the control is already mapped to answers200withcreated: false. - A mapping made here is recorded as made by hand, so a later starter-controls call never overwrites it.
DELETE /api/v1/controls/{id}/crosswalks?frameworkKey=iso-9001&requirementRef=4.1 removes one
mapping. The mapping is named in the query string, not a body, and an unknown query parameter is a
400. The framework does not need to be applied, so an old mapping stays removable. A control that
is not mapped to that clause answers 404.
curl -X POST https://app.keelgrc.com/api/v1/frameworks/iso-9001/starter-controls \ -H "Authorization: Bearer $KEEL_API_KEY"Examples
Section titled “Examples”Check your connection and see which organization the key belongs to:
curl https://app.keelgrc.com/api/v1/me \ -H "Authorization: Bearer $KEEL_API_KEY"Read ISO 27001 readiness (useful for surfacing posture in your own dashboards):
curl https://app.keelgrc.com/api/v1/readiness \ -H "Authorization: Bearer $KEEL_API_KEY"Create a task:
curl -X POST https://app.keelgrc.com/api/v1/tasks \ -H "Authorization: Bearer $KEEL_API_KEY" \ -H "content-type: application/json" \ -d '{"title":"Rotate database credentials","description":"Quarterly rotation"}'Create a control with your own key:
curl -X POST https://app.keelgrc.com/api/v1/controls \ -H "Authorization: Bearer $KEEL_API_KEY" \ -H "content-type: application/json" \ -d '{"key":"AC-1","name":"Access reviews","description":"Quarterly review of user access"}'Report a piece of evidence by link:
curl -X POST https://app.keelgrc.com/api/v1/evidence \ -H "Authorization: Bearer $KEEL_API_KEY" \ -H "content-type: application/json" \ -d '{"url":"https://example.com/export.pdf","title":"MFA enforcement export"}'Webhooks
Section titled “Webhooks”Register a URL with POST /api/v1/hooks to receive event notifications when things change in your
workspace, then wire them into Zapier or your own endpoint. List your
subscriptions with GET /api/v1/hooks and remove one with DELETE /api/v1/hooks/{id}.
A subscription takes targetUrl and event. event is "all", which receives every event, or
the name of one event Keel emits, such as task.created. Omit it and the subscription is "all".
Any other value is a 400, nothing is created, and the message lists every valid name. For several
events, create one subscription per event.
Rotating a secret and unsubscribing require the owner or admin role, and subscribing requires a non-auditor. Only listing is open to any key. A key created before keys carried a member has no role at all and is refused by all three: replace it under Settings if an existing integration stops working.
GET /api/v1/hooks shows each subscription’s full targetUrl only to an owner or admin. Any other
key sees the origin alone (https://host), because a full target URL can work as a credential for
the endpoint that receives it.
The signing secret
Section titled “The signing secret”The 201 response to POST /api/v1/hooks carries secret, a string that starts with whsec_.
It is shown once. No endpoint returns it again, GET /api/v1/hooks included, so store it with
your receiving endpoint when you subscribe.
{ "id": "5b0e8f7a-2c4d-4e6f-8a1b-3c5d7e9f0a2b", "targetUrl": "https://example.com/hook", "event": "control.status_changed", "secret": "whsec_3f9a0c1e7b2d4a6f8e0c1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f3a"}If the server is not set up to sign webhooks, POST /api/v1/hooks answers 503 and creates
nothing. The same request succeeds once signing is configured.
Rotating a secret
Section titled “Rotating a secret”If a secret is lost or exposed, POST /api/v1/hooks/{id}/rotate replaces it. Send no body, or
{}. The response carries the new secret, again shown once, and previousSecretExpiresAt:
{ "id": "5b0e8f7a-2c4d-4e6f-8a1b-3c5d7e9f0a2b", "secret": "whsec_9b1d3f5a7c9e0b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d", "previousSecretExpiresAt": "2026-10-01T09:30:00.000Z"}For 24 hours after a rotation, until previousSecretExpiresAt, every delivery is signed with both
the new secret and the old one. You can move your receiver to the new secret at any point in that
window without missing an event. Rotating again inside the window replaces the older secret, which
stops signing at once. An id that is not a subscription in your workspace answers 404, and the
route answers 503, changing nothing, when signing is not configured.
What a delivery looks like
Section titled “What a delivery looks like”Keel POSTs each event to your URL as application/json, with the body
{ id, event, data, orgId, at }. id is a UUID for the event, event is its name, orgId
identifies your workspace, and at is the ISO-8601 time the event was emitted. The shape of data depends on the
event, and the OpenAPI spec lists the events. Each delivery also carries these headers:
| Header | Value |
|---|---|
x-keel-event-id |
The body’s id. Every subscription an event reaches gets the same value, and a retry sends it again. |
x-keel-subscription-id |
The id that POST /api/v1/hooks returned for this subscription. |
x-keel-signature |
v1=<hex>: the lowercase hex HMAC-SHA256 of the raw request body, keyed with the whole secret string, whsec_ prefix included. During a rotation window it is v1=<current>,v1=<previous>, with the new secret’s signature first. |
Keel retries a delivery that fails with a network error, a 429 or a 5xx, resending the same
body. It does not retry a 4xx, so answering 401 to a delivery that fails verification ends it.
Verifying a delivery
Section titled “Verifying a delivery”- Read the raw body before parsing it. The signature covers the exact bytes Keel sent. If you parse the JSON and serialise it again, key order or whitespace can change and the signature will not match.
- Check the signature. Compute HMAC-SHA256 of the raw body with your secret as the key, as
lowercase hex. Split
x-keel-signatureon commas, keep the values that start withv1=, and accept the delivery if your HMAC equals any of them. Compare in constant time. Ignore values with any other prefix, so a later signature version can appear besidev1without breaking you. Reject the delivery if the header is missing or nothing matches. - Check
atto refuse replays.atis inside the signed body, so it cannot be changed without breaking the signature. Reject a delivery whoseatis more than a few minutes old. Keel’s retries finish within tens of seconds, so a window of minutes does not turn away a genuine retry. - Deduplicate on the event id. Delivery is at least once, so the same event can arrive more
than once. Record each
x-keel-event-idyou have processed and skip one you have seen.
The two examples below cover steps 2 and 3. Step 1 depends on your framework, and step 4 needs a store of your own for the ids you have handled.
In Node, with the body read as a Buffer (for example express.raw({ type: 'application/json' })):
import { createHmac, timingSafeEqual } from 'node:crypto';
const MAX_AGE_MS = 5 * 60 * 1000;
// rawBody is a Buffer holding the request body exactly as received.export function verifyKeelDelivery(rawBody, signatureHeader, secret) { if (!signatureHeader) return false; const expected = Buffer.from(createHmac('sha256', secret).update(rawBody).digest('hex')); const matched = signatureHeader .split(',') .map((value) => value.trim()) .filter((value) => value.startsWith('v1=')) .some((value) => { const given = Buffer.from(value.slice(3)); return given.length === expected.length && timingSafeEqual(given, expected); }); if (!matched) return false; const { at } = JSON.parse(rawBody.toString('utf8')); return Math.abs(Date.now() - Date.parse(at)) <= MAX_AGE_MS;}In Python:
import hashlibimport hmacimport jsonfrom datetime import datetime, timezone
MAX_AGE_SECONDS = 5 * 60
# raw_body is the request body as bytes, exactly as received.def verify_keel_delivery(raw_body, signature_header, secret): if not signature_header: return False expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest().encode() values = [v.strip() for v in signature_header.split(",")] if not any( hmac.compare_digest(expected, v[3:].encode()) for v in values if v.startswith("v1=") ): return False at = datetime.fromisoformat(json.loads(raw_body)["at"].replace("Z", "+00:00")) age = datetime.now(timezone.utc) - at return abs(age.total_seconds()) <= MAX_AGE_SECONDSNext steps
Section titled “Next steps”- MCP server: connect AI agents to the same API in natural language.
- API & MCP overview: how the integration surfaces fit together.