> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# OTP approvals workflow: run the two-officer gate end to end

> Assemble the full two-officer loop for Verify OTP approvals — policy, queue, decision, execution, and webhooks — with worked curl on all five approvals routes.

# OTP approvals workflow

This guide assembles the two-officer approval loop for Verify end to end: turn the gate on, queue a change, take a decision, watch it execute, and wire the notifications. The per-field reference lives in [OTP approvals](/verify/otp-approvals); the setup walkthrough is [Second-officer approvals for Verify](/guides/verify-approvals-two-officer). Use this page when you want the whole loop — policy, queue, decision, execution, webhooks — in one run-through with worked curl on every route.

## The five-route group

The approvals API is one group of five routes under `/api/v1/verify/approvals`:

| Route                                       | Role                           | Purpose                                                                                                           |
| ------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `GET /api/v1/verify/approvals/settings`     | any authenticated member       | Read the policy. A requester calls this to predict whether their next template edit or resend burst will queue.   |
| `PUT /api/v1/verify/approvals/settings`     | owner/admin                    | Update the policy: the master switch, the optional resend-burst threshold, and the burst window.                  |
| `GET /api/v1/verify/approvals/pending`      | owner/admin                    | List the queue, oldest first. Optional `limit` (1–100, default 50) and `kind` (`otp_template` or `resend_burst`). |
| `POST /api/v1/verify/approvals/:id/approve` | owner/admin, not the requester | Release the queued item.                                                                                          |
| `POST /api/v1/verify/approvals/:id/reject`  | owner/admin                    | Hold the item, with an optional `reason` for the requester.                                                       |

The same surface is in the dashboard at **Outbound → OTP approvals** — the queue and the Policy card sit on one page, and only owners and admins see it.

## The two-officer workflow

The loop branches through create → review → commit:

1. **Create.** A requester edits an OTP template or drives resends past the configured burst threshold. Their change queues as a `pending` row instead of applying. A requester who reads `GET /settings` first can predict this.
2. **Review.** An approver (an owner or admin who is *not* the requester) opens **Outbound → OTP approvals** or lists `GET /pending`. Template rows show the exact rendered body to judge; burst rows show the count and window.
3. **Commit.** The approver posts `approve` (release) or `reject` (hold, optional `reason`). Approving as the requester returns `409 FOUR_EYES_VIOLATION` and nothing moves. Approved items execute — the template goes live, or the burst re-enters the normal dispatch chain. Rejected items stop; the requester may re-submit, which queues a fresh row.

## Worked curl across the loop

### 1. Read the policy (requester)

```bash theme={null}
curl -X GET 'https://api.orbit.devotel.io/api/v1/verify/approvals/settings' \
  -H 'X-API-Key: dv_live_sk_your_key_here'
```

```json 200 theme={null}
{
  "data": {
    "require_approval_default": false,
    "resend_burst_threshold": null,
    "resend_burst_window_seconds": 60
  },
  "meta": {
    "request_id": "req_0a1b2c",
    "timestamp": "2026-09-05T12:00:00.000Z"
  }
}
```

### 2. Set the policy (approver)

Send `require_approval_default` on every update — it is required. `resend_burst_threshold` accepts a positive integer or `null` (gate **every** burst); the window defaults to 60 seconds (max 86,400).

```bash theme={null}
curl -X PUT 'https://api.orbit.devotel.io/api/v1/verify/approvals/settings' \
  -H 'X-API-Key: dv_live_sk_your_key_here' \
  -H 'Content-Type: application/json' \
  -d '{"require_approval_default": true, "resend_burst_threshold": 25, "resend_burst_window_seconds": 300}'
```

```json 200 theme={null}
{
  "data": {
    "require_approval_default": true,
    "resend_burst_threshold": 25,
    "resend_burst_window_seconds": 300
  },
  "meta": {
    "request_id": "req_9f8e7d",
    "timestamp": "2026-09-05T12:05:00.000Z"
  }
}
```

### 3. Review the queue (approver)

```bash theme={null}
curl -X GET 'https://api.orbit.devotel.io/api/v1/verify/approvals/pending?limit=50' \
  -H 'X-API-Key: dv_live_sk_your_key_here'
```

```json 200 theme={null}
{
  "data": {
    "items": [
      {
        "id": "vap_01jxm1p8qf2ek9xtfa0kwewb4n",
        "kind": "resend_burst",
        "template_id": null,
        "verification_id": "vrf_02a4e7cfd7b54f0a94db3382b42d0db1",
        "burst_count": 9,
        "window_seconds": 300,
        "requested_by_user_id": "usr_0f3b1c…",
        "request_note": "retry the failed resend before the verification expires",
        "status": "pending",
        "created_at": "2026-08-23T10:11:00.000Z"
      }
    ]
  },
  "meta": {
    "request_id": "req_3c4d5e",
    "timestamp": "2026-09-05T12:10:00.000Z"
  }
}
```

### 4a. Commit: approve

```bash theme={null}
curl -X POST 'https://api.orbit.devotel.io/api/v1/verify/approvals/vap_01jxm1p8qf2ek9xtfa0kwewb4n/approve' \
  -H 'X-API-Key: dv_live_sk_your_key_here'
```

```json 200 theme={null}
{
  "data": {
    "approval_id": "vap_01jxm1p8qf2ek9xtfa0kwewb4n",
    "status": "approved"
  },
  "meta": {
    "request_id": "req_6f7a8b",
    "timestamp": "2026-09-05T12:15:00.000Z"
  }
}
```

If the approver is the requester, this returns `409 FOUR_EYES_VIOLATION` and nothing is recorded.

### 4b. Commit: reject

The body is optional; `reason` goes back to the requester.

```bash theme={null}
curl -X POST 'https://api.orbit.devotel.io/api/v1/verify/approvals/vap_01jxm1p8qf2ek9xtfa0kwewb4n/reject' \
  -H 'X-API-Key: dv_live_sk_your_key_here' \
  -H 'Content-Type: application/json' \
  -d '{"reason": "Template copy needs legal review before it goes live."}'
```

```json 200 theme={null}
{
  "data": {
    "approval_id": "vap_01jxm1p8qf2ek9xtfa0kwewb4n",
    "status": "rejected"
  },
  "meta": {
    "request_id": "req_1a2b3c",
    "timestamp": "2026-09-05T12:20:00.000Z"
  }
}
```

## Webhooks and idempotency

There is no approvals-specific webhook event. Downstream notification rides the standard Verify events: an approved **resend burst** emits the ordinary `verification.sent` event as it dispatches, and an approved **template** shows up in the ordinary verification events of its next transmission. Subscribe to the standard events with the signatures pattern in [Verify webhook signatures](/guides/verify-webhook-signatures) — raw body before any JSON parser, `X-Orbit-Signature` (legacy `X-Devotel-Signature` fallback), answer `401` on failure.

Delivery is at-least-once, so dedupe on the stable envelope `id` (the `Idempotency-Key` header mirrors it) — the same event may arrive twice after an ambiguous outcome. Inside the loop, decisions are naturally idempotent too: approving or rejecting an already-decided item returns `404`, and a self-approve attempt returns `409` before any state moves.

Every queue decision and policy change lands in the [audit log](/guides/audit-log): `verify.approval.approved`, `verify.approval.rejected` (with the `reason`), and `verify.approval.settings.updated`.

## Failure modes

| Symptom                                       | Cause                                                                                            | Fix                                                                                                             |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `409 Conflict` on approve                     | Approver is the requester                                                                        | A different owner or admin decides; the requester can reject their own item with a reason such as "superseded". |
| `403` on queue, decisions, or `PUT /settings` | Role is not owner or admin                                                                       | An owner elevates the member, or an owner/admin works the queue (`GET /settings` stays readable by any member). |
| Empty queue after a submission                | Gate off, or burst below the threshold                                                           | Check the policy; blank or lower `resend_burst_threshold`.                                                      |
| Approved burst did not resend immediately     | Approval only opens the gate; dispatch still follows cooldown, rate limits, and channel fallback | Wait for the normal dispatch cycle.                                                                             |
| Verification expired in the queue             | A queued burst outlived its verification                                                         | The replay is declined by normal resend rules — start a fresh verification.                                     |

## See also

* [OTP approvals reference](/verify/otp-approvals) — policy fields, queue item shape, and role rules.
* [Second-officer approvals for Verify](/guides/verify-approvals-two-officer) — the setup walkthrough.
* [Verify webhook signatures](/guides/verify-webhook-signatures) — per-language signature handlers.
* [Audit log](/guides/audit-log) — the decision trail.
