Approvals
An approval checkpoint holds one specific operator action until a second qualified person approves it. Kontier ships a fixed catalog of four checkpoints; a workspace switches on the ones it wants, optionally above a value threshold, and everything parked lands in one approvals queue.
A request is decided by a person in the dashboard; there is no API for deciding. Over the API you can configure checkpoints (they are organization settings), read the queue and each request, learn from a gated call that it was parked, and receive the outcome as a webhook event.
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 or sending a quote. 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 |
change.gate | A scheduled change created with "gate": "approval". It runs at its instant only after a second person with change.write releases it; the author cannot release their own. | per change |
The catalog is not extensible. 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
Checkpoints are configured per workspace as organization settings, with PUT /v1/organization/settings. quote.send, invoice.refund and wallet.adjust each have three keys, approval.<checkpoint>.<field>:
enabled— off by default on every checkpoint.threshold— an optional currency-tagged amount; the operation gates at or above it. No threshold means every such operation gates; it is never inferred from a zero amount. Only checkpoints with an amount to compare can carry one. A request in another currency always gates, because a threshold never converts.approver_role— optionally narrows who may decide to a single role, on top of the permission rules below. Empty means anyone otherwise eligible.
change.gate has no settings: a change opts in with "gate": "approval".
Gate refunds of 500 EUR and more
Saved from a live workspace, the values land at the organization and govern its sandboxes too; send "level": "workspace" for one workspace only. Approval settings never inherit into organizations below yours. Read the current values with GET /v1/organization/settings, and the checkpoints themselves (what each one holds, the permission a decider needs, the eligible deciders under the current approver role, and the notice to show before switching it on) with GET /v1/approvals/checkpoints.
Writing these keys needs the approval.configure permission on top of the settings permission, and an API key needs it in its scopes. It is kept apart from deciding, so whoever releases parked requests cannot also remove the gate. Enabling a checkpoint is refused when fewer than two people could decide its requests (422 NO_ELIGIBLE_APPROVERS), and an approver role without the checkpoint's domain permission is refused (422 ROLE_LACKS_PERMISSION). A gate nobody can clear makes the operation permanently impossible, so it cannot be switched on by accident. Switching a checkpoint off is never refused.
Enabling a checkpoint locks out API keys
While a checkpoint is on, the operation it gates cannot be performed with an API key (see below). An integration that switches on the refund gate stops being able to refund.
Who may decide
Deciding, cancelling and retrying a request have no API. The person who decides must meet all of the following:
- Permission to read approval requests.
- Permission to decide approval requests.
- The checkpoint's own domain permission — you cannot bless an operation you could not perform yourself.
- The configured approver role, when one is set.
- You did not raise the request yourself (
SELF_APPROVAL).
API keys cannot decide, and are refused rather than parked. A request from an API key against an enabled checkpoint answers 409 APPROVAL_REQUIRED_HUMAN. A machine caller can never satisfy separation of duties, 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
These operations can be parked:
| Operation | Checkpoint |
|---|---|
POST /v1/invoices/{id}/refund | invoice.refund |
POST /v1/wallets/{id}/adjust | wallet.adjust |
POST /v1/quotes/{id}/submit, POST /v1/quotes/{id}/send | quote.send |
When a signed-in operator's call is parked, it answers 202 Accepted with an ApprovalPending body:
Code
The operation has not been carried out: no money moved, no ledger row was written, no document was sent. 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; approval_request_id is then the existing request's id. A double-clicked button is a no-op, not a conflict.
Keep approval_request_id: it is how you match the outcome when it arrives.
Following the outcome
Read a request with GET /v1/approvals/{id}: its status, the facts and money lines the decision turns on, and the decision trail. GET /v1/approvals lists the queue, newest first, with filters such as status=PENDING or checkpoint_id=invoice.refund.
Pending refund approvals
To react without polling, subscribe to the approval.* webhook events: every one carries request_id, which equals the approval_request_id you received, plus checkpoint_id and the subject (entity_type / entity_id), so you can route and act without a follow-up read.
| Event | Fires when |
|---|---|
approval.request_created | An operator action was parked and needs a decision. Carries the deadline (expires_at) and who raised it. |
approval.request_approved | A person approved it; the operation is released and runs just after. |
approval.request_rejected | A person rejected it, with their reason in comment. |
approval.request_expired | Nobody decided before the deadline; the request closed EXPIRED. |
approval.request_cancelled | The requester withdrew it, or it was retired because its subject was voided, ended or changed; cause says which (requester, subject_retired, subject_changed). |
approval.applied | The approved operation has run. This, not request_approved, is the moment the refund, adjustment or send actually happened. |
approval.apply_failed | The approved operation failed to run. It waits for a retry, with the error in error. |
approval.withdrawn | What a rejected, cancelled or expired request had parked was released. |
approval.withdraw_failed | Releasing what a closed request had parked failed; the subject stays parked until a retry succeeds. |
approval.request_retried | An operator re-armed a failed run (the apply or the withdraw). |
Approving and applying are separate steps 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 therefore fail after the approval is real. When it does you receive approval.apply_failed, and the request stays retryable from the dashboard. 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, cancellation and expiry all 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.
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; registering them is refused.