Skip to content

Approvals API

Approvals are decision requests with named approvers, a policy, and a frozen snapshot of what is being decided. See the Approvals user guide for the product behaviour.

All routes use API key authentication and run with the key owner’s live permissions. An approval is visible to its requester, its approvers, and holders of the approval:manage capability; anything else returns 404.

GET /api/v1/organizations/:orgId/approvals?tab=waiting

The tab query parameter selects the view:

  • waiting (default): pending approvals waiting on the caller’s decision.
  • assigned: every approval the caller has ever been assigned.
  • requested: approvals the caller raised.
  • all: every approval in the organisation. Requires approval:manage. Supports status, subjectType, and subjectId filters.

limit (max 200) and offset page the results.

GET /api/v1/approvals/:id

Returns the approval with its snapshot summary rows, resolved entity links, per-approver assignments (including decisions and comments), the full audit timeline, and a canDecide flag for the caller.

For budget change orders, terminal decisions also close the current proposal after processing. Live budget amounts remain unchanged on rejection, requested changes, expiry or cancellation. Read both records to confirm the result; see change-order outcomes.

Purchase-order and supplier-bill approval amounts require finance:view_cost in the project/client scope recorded on the request. This is independent of finance:view_prices. A cost-only caller receives the cost summary and snapshot. Without cost authority, the title is general, description and subjectFacts are absent, and subjectSnapshot contains only nonfinancial summary rows. Its data and renderedMarkdown are absent. This also applies to older requests.

The same projection applies to lists and mutation replies. The named MCP list_approvals, get_approval, create_approval, decide_approval and cancel_approval tools apply the same permissions. Approval visibility and decision authority remain separate from cost visibility.

POST /api/v1/approvals/:id/decisions

{ "decision": "approved", "comment": "Looks good" }

decision is approved, rejected, or changes_requested; comment is optional except for changes_requested, where it is required (400 COMMENT_REQUIRED). The first decision per approver is final: repeating the same decision returns the recorded result (idempotent, including after the approval has closed), while a conflicting one returns 409 ALREADY_DECIDED. A decision on a closed approval returns 409 APPROVAL_CLOSED, on a reassigned-away assignment 409 NO_LONGER_REQUIRED, and on a stage that is not yet (or no longer) active 409 STAGE_NOT_ACTIVE.

The policy resolves the request automatically: any completes on the first response, all on unanimity (one rejection rejects), and quorum when the configured number of approvals is reached or can no longer be reached. A single changes_requested decision closes the request under every policy. On a staged approval, only the active stage’s approvers can decide; an approved stage advances the request to the next stage, and the request completes when the final stage approves.

POST /api/v1/approvals/:id/cancel withdraws a pending request. Allowed for the requester and approval:manage holders. Optional reason.

POST /api/v1/approvals/:id/reassign moves one pending assignment to another member. Requires approval:manage.

{ "fromAssignmentId": "…", "toUserId": "…" }

POST /api/v1/organizations/:orgId/approvals (requires approval:manage)

{
"title": "Approve the Q4 media budget",
"approvers": [
{ "kind": "user", "id": "…" },
{ "kind": "group", "id": "…" }
],
"policy": { "type": "quorum", "quorum": 2 },
"subjectSnapshot": { "summary": [{ "label": "Amount", "value": "$120,000" }] },
"entityLinks": [{ "type": "project", "id": "…", "label": "Q4 Media" }],
"dueAt": "2026-09-15T00:00:00Z"
}

Groups expand to their current active members when the approval is created. An empty expansion is rejected rather than auto-approving.

Instead of a flat approvers + policy pair, pass stages, an ordered array (max 10) of { "approvers": […], "policy": { … } } objects. Stages decide sequentially; approvers in later stages are notified when their stage becomes active. One user cannot appear in more than one stage.

PUT /api/v1/organizations/:orgId/approval-delegations/me sets the caller’s delegation:

{ "delegateUserId": "…", "endsAt": "2026-09-15T00:00:00Z" }

While active, new assignments that would go to the caller go to the delegate instead (recorded with delegatedFromUserId), unless the delegate is already an approver on the same request, in which case the substitution is skipped. Delegations never chain. GET …/me reads it, DELETE …/me revokes it, and GET /api/v1/organizations/:orgId/approval-delegations lists all live delegations (requires approval:manage).

GET /api/v1/organizations/:orgId/approval-policies lists the configurable gates (currently project.draft_activation) with their approvers.

PUT /api/v1/organizations/:orgId/approval-policies/:gateKey configures one:

{
"enabled": true,
"policyType": "any",
"dueHours": 48,
"autoApply": true,
"expiryBehavior": "expire",
"approvers": [{ "kind": "group", "id": "…" }]
}
  • autoApply: accepted for compatibility and always stored as true. On approval, Runnit performs the gated transition on the requester’s behalf. If a lifecycle guard still blocks it, the failure is audited on the approval and the requester is notified; the approval remains valid for a manual retry. A request raised by one of the gate’s approvers records their approval as it is created (not under segregation of duties).
  • expiryBehavior: what dueHours passing does. expire (default), auto_approve, or auto_reject.
  • clientOrganizationId: include it to create or update a client-scoped override instead of the org default. Resolution is most specific wins, so a client row (enabled or disabled) beats the default for that client’s projects. DELETE …/approval-policies/:gateKey?clientOrganizationId=… removes an override; without the parameter it removes the default.

All of these require approval:manage.

For decisions made in an external system (Microsoft Power Automate, Logic Apps, or anything that can send signed HTTP):

PUT /api/v1/organizations/:orgId/approval-webhooks/generic with { "enabled": true, "rotateSecret": true } enables the receiver and returns the signing secret ONCE (afterwards only a hint is readable). Requires approval:manage.

The external system then posts to POST /api/approval-webhooks/generic (no API key; authentication is the signature):

{
"organizationId": "…",
"approvalRequestId": "…",
"approverEmail": "director@example.com",
"decision": "approved",
"comment": "Signed off in Teams"
}

with headers X-Approval-Timestamp (unix seconds, within 5 minutes) and X-Approval-Signature: sha256=<hex HMAC-SHA256 of "{timestamp}.{rawBody}"> computed with the signing secret. The approver email must belong to an active member of the organisation; the decision then follows exactly the same rules as any other surface.

With the project.draft_activation gate enabled, moving a draft project to an active status returns 409 with a machine-readable code:

  • APPROVAL_REQUIRED: no approval exists yet. Create one with POST /api/v1/projects/:id/request-activation-approval (no body); the gate’s configured approvers are notified.
  • APPROVAL_PENDING: a request is open; details.approvalRequestId links to it.

Once approved, retry the status change. The approval is consumed atomically with the transition: one approval authorises one activation.