Approvals
An approval checkpoint holds one specific operator action until a second qualified person approves it. Kontier ships a fixed catalog of four checkpoints; you switch the ones you want on, optionally above a value threshold, and everything parked lands in one queue at /v1/approvals.
Why this matters. A small number of billing actions are irreversible or expensive: money leaving through the gateway, a balance written by hand, a price committed to a customer. Those deserve a second pair of eyes. Almost everything else does not, and gating it slows the business down for no gain. So the catalog is short, it is chosen rather than configured, and every checkpoint sits on an operator's intent — never on the engine that carries it out. Your billing run is never waiting on somebody to click approve.
The catalog
| Checkpoint | What it holds | Default |
|---|---|---|
quote.send | Submitting a quote for send. The quote parks in PENDING_APPROVAL; on approval it becomes APPROVED and the deal owner sends it when ready. | off |
invoice.refund | An operator-initiated refund. Nothing is sent to the payment provider until it is approved. | off |
wallet.adjust | An operator-initiated wallet adjustment. No ledger entry is written and the balance does not move. | off |
schedule_flow.gate | An approval step an author placed inside a schedule flow. | authored per flow |
The catalog is not extensible through the API. There is no template builder, no trigger vocabulary and no entity/action matrix: a checkpoint is part of the product, which is what lets each one describe the specific thing it is holding and show the approver the figures that matter for that decision.
Automated paths are never gated. invoice.refund and wallet.adjust cover the manual endpoints only. The same underlying operations, when they are reached by the billing cycle, subscription cancellation or promotion clawback, run without approval — by design. A gate that covers the rare manual path while advertising total coverage is worse than no gate, because it is trusted.
Configuring a checkpoint
GET /v1/approval-checkpoints lists the catalog with each checkpoint's current settings; PATCH /v1/approval-checkpoints/{checkpoint_id}/settings updates them. It is a partial update — fields you leave out keep their stored value.
enabled— off by default on every configurable checkpoint.threshold_mode—EVERYorAT_OR_ABOVE.AT_OR_ABOVErequires athreshold.threshold— a currency-tagged amount. A command gates at or above it. UnderEVERYthere is no threshold at all; "no threshold" means all, and is never inferred from a zero amount.approver_role— optionally narrows who may decide to a single role, on top of the permission rules below.
Enabling a checkpoint is refused with 422 NO_ELIGIBLE_APPROVERS when fewer than two people in the workspace could decide its requests. A gate nobody can clear makes the operation permanently impossible, so it cannot be switched on by accident.
Who may decide
All of the following must hold, and each failure has its own code so the reason is always specific:
approval.read— to see the request at all (APPROVAL_NOT_VISIBLE).approval.decide— to decide requests (NOT_APPROVAL_DECIDER).- The checkpoint's own domain permission — you cannot bless an operation you could not perform yourself (
MISSING_DOMAIN_PERMISSION). - The configured
approver_role, when one is set (NOT_IN_APPROVER_ROLE). - You did not raise the request (
SELF_APPROVAL).
API keys are refused, not parked. A request from an API key against an enabled checkpoint answers 409 APPROVAL_REQUIRED_HUMAN with the checkpoint id in the error detail. A machine caller can never satisfy separation of duties and can never consume a decision it will not make, and an integration must not become the quiet way around a gate you just switched on. The trade-off is real: an organization whose refunds run through an integration cannot enable that checkpoint at all.
What a gated call returns
202 Accepted with an ApprovalPendingDTO:
Code
state is the discriminator, and it is the same on every gated endpoint. It matters most on refunds, which have a second 202 (status: "refund_pending") meaning the money has already left and the provider is settling. Branch on state == "approval_pending" and the two can never be confused.
already_pending: true means a request for this subject was already waiting and this call raised nothing new. A double-clicked button is a no-op, not a conflict — one pending request per (organization, checkpoint, subject) is enforced by the database.
Deciding
| Operation | Effect |
|---|---|
GET /v1/approvals | The queue: pending first, with the summary each checkpoint built. |
GET /v1/approvals/{id} | One request with its facts, its money lines and its decision history. |
POST /v1/approvals/{id}/decisions | Approve or reject, with an optional comment. |
POST /v1/approvals/{id}/cancel | The requester withdraws their own request. |
POST /v1/approvals/{id}/retry-apply | Re-run an apply that failed. |
Approving records the decision and enqueues the operation; the request then reports apply_state as it runs. Because the operation happens just after the decision rather than inside it — a refund calls the payment provider, a quote needs two state transitions — an apply can fail after the approval is real. When it does the request is badged APPROVED with apply_state: "FAILED" and the error, and stays retryable. It is never dropped quietly.
A request is never approved by anything but a person. An expiry deadline can move a request to EXPIRED, and rejection and expiry both withdraw the parked operation. There is no auto-approve: no path leads from "time passed" to APPROVED.
Statuses
PENDING → APPROVED, REJECTED, EXPIRED or CANCELLED. All four outcomes are terminal.
Webhooks
Five events, one per thing that can happen to a request. Every one of them has a producer — subscribe to any of them and it will fire.
| Event | Fires when |
|---|---|
approval.request_created | An operator action was parked and needs a decision. Carries the approver's headline, the checkpoint, the subject and the deadline, so an integrator can build their own inbox without reading back. |
approval.request_approved | A person released the operation. The operation itself runs just after, as a job — see apply_state. |
approval.request_rejected | A person refused it, with their reason: comment is always present, because a rejection without one is refused at the API. |
approval.request_expired | Nobody decided before the deadline; the request closed EXPIRED and the parked operation was withdrawn. |
approval.request_cancelled | The requester withdrew their own request. |
Every payload carries organization_id, checkpoint_id and the subject (entity_type / entity_id), so a subscriber can route and act on one without a follow-up read.
There is no event for a step, because there are no steps. If you are looking for approval.step_started, approval.step_approved, approval.step_auto_approved, approval.step_escalated or approval.request_auto_rejected, they described the workflow engine this model replaced and are gone. request_auto_rejected in particular never meant what it said — nothing rejects on the operator's behalf, and what it was reaching for is approval.request_expired.