Change requests API
When a workspace requires approval for a kind of change, the endpoint that makes it doesn’t apply it: it creates a change request that a second person approves. This page covers how scripts handle that, and the endpoints behind Approvals, Settings → Approvals, Settings → Roles and Settings → Audit log. For what maker-checker is and which changes it covers, read Approvals first.
Before you start
Section titled “Before you start”Who can call them. Signed-in people only. Change requests have a maker and a checker who are
different named people, so API keys can’t use these endpoints. Sign in as described in
Alerts and cases API: before you start and add
-b ~/.sp-cookies to each request.
When a change needs approval
Section titled “When a change needs approval”These endpoints can be gated, depending on the workspace’s policy:
| Endpoint | Gated as | When |
|---|---|---|
PUT /api/v1/settings/workspace |
settings.security, settings.decisions, settings.four_eyes, settings.case_sla, settings.jurisdiction |
require_mfa is switched off, or the decision policy, four-eyes, case SLAs, jurisdiction or identifier checks change. Switching require_mfa on, and the name, time zone and language alone, apply directly. A save touching several gated sections is one request, filed under the first of these in this order |
PUT /api/v1/settings/reporting |
settings.reporting |
Always |
PUT /api/v1/approvals/policy |
approvals.policy |
Always |
POST /api/v1/users |
users.roles |
The roles include a privileged permission: management, approval, entities.view_pii, audit.view, designations.manage or a reports.* permission |
PUT /api/v1/users/{id}/roles |
users.roles |
The roles add a privileged permission the person didn’t have |
POST /api/v1/users/{id}/enable |
users.enable |
Always |
POST /api/v1/users/{id}/reset-2fa, POST /api/v1/users/{id}/reset-password |
users.credentials |
For someone other than yourself |
POST /api/v1/roles, PUT /api/v1/roles/{key}, DELETE /api/v1/roles/{key} |
roles.manage |
Always |
PUT /api/v1/settings/sso, PUT /api/v1/settings/sso/break-glass |
sso.config |
Always |
POST /api/v1/settings/sso/domains, DELETE /api/v1/settings/sso/domains/{id} |
sso.config |
Always. Verifying a domain (POST …/domains/{id}/verify) is never gated |
POST /api/v1/api-keys |
api_keys.create |
Always |
POST /api/v1/webhooks |
webhooks.endpoint |
Always |
PATCH /api/v1/webhooks/{id} |
webhooks.endpoint |
The url changes |
POST /api/v1/data-sources |
datasources.connection |
Always |
PUT /api/v1/data-sources/{id} |
datasources.connection |
The config changes or credentials are sent |
POST /api/v1/data-sources/{id}/cdc/resync, POST …/cdc/drop-slot |
datasources.connection |
Always |
DELETE /api/v1/data-sources/{id}?force=true |
datasources.connection |
A Postgres replication source whose slot still exists. Other deletes are never gated |
PUT /api/v1/data-sources/{id}/mapping |
datasources.mapping |
Always (a new mapping version) |
POST, PATCH, DELETE /api/v1/lists/{key}/items; DELETE /api/v1/lists/{key} |
lists.change |
The list is a deny or allow list |
POST /api/v1/rule-packs/{key}/install, POST /api/v1/rule-packs/{key}/upgrade |
rules.packs |
Always |
POST /api/v1/rule-packs/bundles |
rules.packs |
Always, after the signature is checked. The request is about the bundle’s pack, so one pending request per pack covers installing, upgrading and importing. Approval re-checks rules.packs_downgrade for a forced downgrade, as the maker |
A gated endpoint answers in one of three ways:
-
The change is invalid. You get the same error as without approval (
409,422…), and nothing is stored. SpeculaPro checks the change by trying it in a transaction it rolls back. -
No reason given:
428 Precondition Required.{"status": 428,"title": "Reason required","detail": "Decision policy needs a second person's approval in this workspace. Say why you are making this change (X-Change-Reason header).","action_type": "settings.decisions","action_name": "Decision policy"} -
A reason given:
202 Accepted, with the request inchange_request:{"status": 202,"title": "Sent for approval","detail": "This change applies once someone with settings.approve approves it. It expires 2026-10-17T06:44:53Z.","change_request": { "id": "01a1248e-…", "status": "pending", "…": "…" }}
Give the reason in the X-Change-Reason header: 3 to 2,000 characters, percent-encoded
UTF-8 when it isn’t plain ASCII. A shorter reason is refused with 422 invalid: give a reason of 3 to 2000 characters.
curl -s -b ~/.sp-cookies -X PUT "$SP/api/v1/settings/workspace" \ -H 'Content-Type: application/json' \ -H 'X-Change-Reason: Allow 200 ms for decisions: the card switch now waits 300 ms (ticket OPS-412)' \ --data @settings.jsonA script should treat 202 as “not done yet”: the change applies when someone approves it. Poll
GET /api/v1/change-requests/{id}, or let the maker follow it under Approvals.
If a request is already waiting for the same target and kind of change, the answer is 409 a change request for this is already waiting for approval. The same applies to proposals that create something, matched
on what they would create: an invitation by email, an API key by name and scopes (case and order
ignored), a webhook by URL, a data source by name (case ignored), a custom role by key, and a single
sign-on domain claim by domain. The database enforces this with a unique index on the request’s
target key.
The change request object
Section titled “The change request object”| Field | Meaning |
|---|---|
id |
Its ID |
action_type, action_name |
The kind of change, such as users.roles / Role assignments |
operation |
What approving runs, such as user.set_roles |
target_type, target_id, target_label |
What it changes; target_id is null when it creates something |
summary |
One line, such as Workspace settings: change the decision policy |
before, after |
The target when proposed (null if it didn’t exist) and once applied, as computed at the proposal. Credentials never appear here |
reason |
The maker’s reason |
status |
pending, applied, rejected, withdrawn, expired or stale |
requested_by, requested_at, expires_at |
The maker (id, name), when, and when it expires (7 days later). The worker closes an overdue request within about a minute of that time, as expired, and tells the maker; nobody needs to open the list for it to happen |
decided_by, decided_at, decision_note |
Who decided, when, and their note |
result |
What applying produced, such as the new key’s or endpoint’s ID |
has_secret, secret_label |
A one-time secret waits for the maker to collect. Always false for anyone else |
can_approve, can_withdraw |
Whether you can approve or withdraw it now |
Change request endpoints
Section titled “Change request endpoints”| Endpoint | Does | Success |
|---|---|---|
GET /api/v1/change-requests |
List requests | 200 |
GET /api/v1/change-requests/counts |
{"inbox": n, "mine_pending": n} for the menu badge |
200 |
GET /api/v1/change-requests/{id} |
One request | 200 |
POST /api/v1/change-requests/{id}/approve |
Approve and apply it. Body optional: {"note": "…"} |
200 |
POST /api/v1/change-requests/{id}/reject |
Reject it. Body: {"note": "…"}, at least 3 characters |
200 |
POST /api/v1/change-requests/{id}/withdraw |
Withdraw your own. Body optional | 200 |
POST /api/v1/change-requests/{id}/secret |
Collect the one-time secret of your applied request: {"label": "…", "secret": "…"}. Shown once |
200 |
Listing. Query parameters: view (inbox: pending requests from others that you can approve,
the default; mine; all), status, action_type, target_type, target_id, limit (1–200,
default 50), and before with before_id for the next page (the requested_at and id of the last
row). You see your own requests, those of any kind you can make or approve, and, with audit.view,
all of them. A request you can’t see answers 404.
Approving re-reads the target first. If it changed since the proposal, nothing is applied, the
request is closed as stale and the answer is 409 stale: the target changed since this was proposed; the request was closed, propose it again. Otherwise the change is applied as the maker,
in one transaction with the approval.
| Status | When | Detail |
|---|---|---|
403 |
Approving or rejecting your own request; missing the approval permission; withdrawing someone else’s | not allowed: you proposed this change; someone else must approve it; not allowed: approving this needs users.approve; not allowed: withdraw your own request instead; not allowed: rejecting this needs the permission to approve it; not allowed: only the person who proposed a change can withdraw it |
404 |
No such request, or you can’t see it | change request not found |
409 |
It is already decided, it expired, it went stale, or the maker can no longer make the change | conflict: this request is already applied; this change request expired; stale: …; conflict: the person who proposed this is no longer active or no longer allowed to make this change; reject it |
422 |
No reason for a rejection | invalid: say why you are rejecting it (at least 3 characters) |
Errors from the change itself (for example a role name now taken) come back as the original endpoint would give them.
The approval policy
Section titled “The approval policy”| Endpoint | Does | Permission |
|---|---|---|
GET /api/v1/approvals/policy |
Each kind of change with the workspace’s choice, plus the always-dual-controlled flows and any pending change to the policy | Any signed-in person |
PUT /api/v1/approvals/policy |
Change it: {"required": {"api_keys.create": true, "lists.change": false}}. Kinds left out keep their setting |
settings.manage |
Each entry in types has the kind’s key, group, name, description, maker_permissions,
approve_permission and default, plus required, configured (false while a default waits for
two approvers), approvers (active people holding the approval permission) and can_enable
(true with two or more). pending_change holds a waiting change to the policy, if any.
The PUT answers 422 for an unknown kind (invalid: unknown action type "…"), an empty change
(invalid: nothing to change), or switching on a kind fewer than two people can approve. Once
approvals.policy itself is required, it answers 428 or 202 like any gated endpoint.
Permissions and custom roles
Section titled “Permissions and custom roles”| Endpoint | Does | Permission |
|---|---|---|
GET /api/v1/permissions |
The catalogue: every permission (key, group, name, description, pii, approval), the kinds of change, and the always-dual-controlled flows |
Any signed-in person |
GET /api/v1/roles |
Built-in and custom roles, with permissions, members and separation-of-duties warnings (sod) |
users.manage or settings.view |
POST /api/v1/roles |
Create a custom role: key (optional), name, description, permissions |
users.manage |
PUT /api/v1/roles/{key} |
Change a custom role’s name, description and permissions | users.manage |
DELETE /api/v1/roles/{key} |
Delete a custom role nobody holds and SSO doesn’t map to | users.manage |
Each may need approval (roles.manage). Errors are 409 or 404:
a role name has 2 to 80 charactersrole keys are lower-case letters, digits and underscores"mlro" is a system roleunknown permission "…"a role needs at least one permissiona role with this name or key already existssystem roles can't be changed; clone it insteadyou hold this role; ask another administrator to add permissions to it2 people hold this role; give them other roles firstsingle sign-on maps to this role; change the SSO role mapping firstthis change would leave too few people to approve changes: …404 role not found
To start from a built-in role, send its permissions in a new role.
The audit log
Section titled “The audit log”GET /api/v1/audit reads the audit log, newest first. It needs audit.view.
| Query parameter | Meaning |
|---|---|
action |
An action, or a prefix such as change_request. |
entity_type |
Such as user, role, change_request |
entity_id |
A UUID |
limit |
1–200, default 100 |
before, before_id |
The created_at and id of the last row, for the next page |
Each record has id, created_at, actor_type, actor_id, actor_name, action,
entity_type, entity_id, before, after, ip and request_id.
# Every change request decided this weekcurl -s -b ~/.sp-cookies "$SP/api/v1/audit?action=change_request.&limit=200"