{
  "openapi": "3.1.0",
  "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
  "info": {
    "title": "AI Approvel Approvals API",
    "version": "2026-10-02",
    "summary": "Human approval for AI agents over WhatsApp/SMS, with a signed, hash-chained audit trail.",
    "description": "A tiny REST API. Your agent creates an approval request, AI Approvel messages a human approver on WhatsApp or SMS, and you poll the status (or receive a signed webhook) to learn the decision. Every state change is written to an Ed25519-signed, hash-chained audit trail that anyone can verify offline.\n\n**Authentication.** Every request carries the project API key as a Bearer token: `Authorization: Bearer apk_live_<8 hex>_<secret>`. Keep the key server-side.\n\n**Conventions.** All bodies are JSON. Ids are UUIDs. Timestamps are RFC 3339 / ISO 8601 in UTC with millisecond precision (`2026-10-01T12:00:02.000Z`). Errors always have the shape `{ \"error\": { \"code\", \"message\", \"details\" } }`.\n\n**Verification status.** This document was checked line by line against the backend source (Supabase edge functions `approvals`, `account` and `optin`) on 2026-10-01, including API build 2026-10, which shipped server-side list filters and cursor pagination, `decision` on list rows, `402 quota_exceeded`, the `Retry-After` header and per-project webhook secrets. No node carries the `x-approv-inferred` flag any more: everything here mirrors the code.\n\n**Dashboard (account) endpoints.** Outside this key-authenticated surface, the dashboard uses `/account/*` with the signed-in user's Supabase JWT (documented at https://ai-approvel.com/docs#account): `POST /account/bootstrap` (`has_api_key`, `api_key_prefix`, `api_key_created_at`, `api_key_last_used_at`), `POST /account/rotate-key` (5 per minute, then `429` + `Retry-After`), `GET /account/usage`, `GET /account/stats` (`period_start`, `total_month`, `decided`, `approved`, `rejected`, `pending`, `expired`, `failed`, `median_time_to_decision_ms`, `approval_rate`, `last_7_days[7]`), `GET` / `POST /account/approvers` (rows carry `locale`; the body accepts `locale: \"en\" | \"ar\"`), `GET /account/webhook-secret` and `POST /account/webhook-secret/rotate` (`{ webhook_secret, created_at }`), and `POST /account/delete` (`202`, or `409 active_subscription`). The public opt-in page uses `GET /optin/:token` (returns `locale`) and `POST /optin/:token` with an optional `{ locale }` body.\n\n**Teams, named API keys and webhook endpoints (Phase C, `x-approv-status: \"phase-c\"`).** Built to docs/PHASE-C-CONTRACTS.md while the backend ships it; nodes carrying that marker describe the contract, not yet verified backend code. `GET /account/members`, `POST /account/members/invite`, `PATCH` / `DELETE /account/members/:id`, `GET /account/invites/:token`, `POST /account/invites/:token/accept`; `GET` / `POST /account/keys`, `DELETE /account/keys/:id`; `GET` / `POST /webhooks/endpoints`, `PATCH` / `DELETE /webhooks/endpoints/:id`, `POST /webhooks/endpoints/:id/rotate-secret`, `POST /webhooks/endpoints/:id/test`, `GET /webhooks/deliveries`, `GET /webhooks/deliveries/:id`, `POST /webhooks/deliveries/:id/replay`. `POST /account/bootstrap` answers `409 pending_invite` (details `{ invite_token, workspace, role, expires_at }`) for a user who has no workspace but an open invite, and now returns `role`. New error codes: `forbidden` (403, role not allowed), `insufficient_scope` (403, the API key lacks the scope), `invite_expired` (410), `invite_mismatch` (403), `last_owner` (409), `pending_invite` (409).",
    "termsOfService": "https://ai-approvel.com/terms",
    "contact": {
      "name": "AI Approvel support",
      "url": "https://ai-approvel.com/docs",
      "email": "security@ai-approvel.com"
    }
  },
  "externalDocs": {
    "description": "Human-readable API reference",
    "url": "https://ai-approvel.com/docs"
  },
  "servers": [
    {
      "url": "https://{project}.supabase.co/functions/v1/approvals",
      "description": "Your AI Approvel deployment: the Supabase functions host plus the `approvals` function. The dashboard sets this through NEXT_PUBLIC_API_BASE.",
      "variables": {
        "project": {
          "default": "your-project-ref",
          "description": "The Supabase project ref of your AI Approvel deployment."
        }
      }
    },
    {
      "url": "https://{project}.functions.supabase.co/approvals",
      "description": "Equivalent legacy functions host. Either form of NEXT_PUBLIC_API_BASE works.",
      "variables": {
        "project": {
          "default": "your-project-ref",
          "description": "The Supabase project ref of your AI Approvel deployment."
        }
      }
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Approvals",
      "description": "Create approval requests and read their status."
    },
    {
      "name": "Audit",
      "description": "The signed, hash-chained audit trail for an approval."
    },
    {
      "name": "Team",
      "description": "Workspace members, roles and invites (user session, Phase C).",
      "x-approv-status": "phase-c"
    },
    {
      "name": "API keys",
      "description": "Named project API keys with scopes and expiry (user session, Phase C).",
      "x-approv-status": "phase-c"
    },
    {
      "name": "Webhook endpoints",
      "description": "Per-project webhook endpoints and the delivery log (user session, Phase C).",
      "x-approv-status": "phase-c"
    }
  ],
  "paths": {
    "/": {
      "post": {
        "tags": [
          "Approvals"
        ],
        "operationId": "createApproval",
        "summary": "Create an approval request",
        "description": "Creates an approval and returns `202` immediately with its `id`. Delivery to the approver happens in the background; poll `GET /{id}` or pass a `callback_url` to be notified.\n\n**Idempotency.** Send an `Idempotency-Key` header (any string, unique per project). A repeat `POST` with the same key returns the original approval with `replayed: true` and the same `202` — nothing is created, nobody is messaged again, and the replay is not charged against your monthly quota. Without the header every request creates a new approval (the server generates a random key for it).\n\n**Validation.** The body is validated strictly: unknown fields are rejected with `400 validation_error`, and `details` carries the field-level errors. The approver must belong to your project and must have confirmed their phone number (opted in); otherwise you get `400 validation_error` with `details.approver_id`.\n\n**Limits.** `POST /` is the only rate-limited route: 60 requests per minute per project by default (`429 rate_limited`), counted before validation. The monthly plan quota is charged only for genuinely new, valid requests.",
        "parameters": [
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateApprovalRequest"
              },
              "examples": {
                "refund": {
                  "summary": "Refund bound to its parameters, with a webhook",
                  "value": {
                    "approver_id": "bb07d4a7-22f6-4188-a139-66f16dc2b6e8",
                    "action": "Refund $4,200 to customer #8831",
                    "amount": 4200,
                    "currency": "USD",
                    "context": {
                      "ticket": "ZD-4411",
                      "source": "support-agent"
                    },
                    "params_hash": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00",
                    "callback_url": "https://agent.example.com/hooks/approv",
                    "locale": "en",
                    "timeout_seconds": 1800,
                    "metadata": {
                      "run_id": "run_42"
                    }
                  }
                },
                "minimal": {
                  "summary": "Minimal request (what the dashboard test sends)",
                  "value": {
                    "approver_id": "bb07d4a7-22f6-4188-a139-66f16dc2b6e8",
                    "action": "Delete the staging database",
                    "context": {
                      "source": "dashboard-test"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted. The approval exists and delivery has been queued. On an `Idempotency-Key` replay the original approval is returned with `replayed: true`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateApprovalResponse"
                },
                "examples": {
                  "accepted": {
                    "summary": "New approval",
                    "value": {
                      "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
                      "status": "pending",
                      "expires_at": "2026-10-01T12:30:02.000Z",
                      "replayed": false
                    }
                  },
                  "replayed": {
                    "summary": "Same Idempotency-Key sent again",
                    "value": {
                      "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
                      "status": "delivered",
                      "expires_at": "2026-10-01T12:30:02.000Z",
                      "replayed": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/QuotaExceeded"
          },
          "403": {
            "description": "`insufficient_scope`: the API key lacks the scope this route needs (Phase C keys with scopes).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "value": {
                      "error": {
                        "code": "insufficient_scope",
                        "message": "This API key lacks the approvals:write scope.",
                        "details": {
                          "required": "approvals:write"
                        }
                      }
                    }
                  }
                }
              }
            },
            "x-approv-status": "phase-c"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "get": {
        "tags": [
          "Approvals"
        ],
        "operationId": "listApprovals",
        "summary": "List approvals",
        "description": "Returns the project's approvals, newest first, as `{ count, approvals, next_cursor, has_more }`. Rows are a summary with the `decision` included (no `params_hash` or `locale`); fetch `GET /{id}` for the full record.\n\n**Filters.** `status` is a lifecycle status, or `approved` / `rejected` meaning *decided with that outcome*. `q` is a case-insensitive substring match on `action` (`%`, `_` and `\\` match literally). **Pagination** is keyset-based: pass `next_cursor` back as `cursor` until `has_more` is `false` (then `next_cursor` is `null`), keeping the same filters across pages. An invalid `cursor`, `status` or `limit` is a `400 validation_error`. Reads are not rate-limited and never count against the monthly quota.",
        "parameters": [
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/status"
          },
          {
            "$ref": "#/components/parameters/q"
          },
          {
            "$ref": "#/components/parameters/cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "The approvals, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApprovalList"
                },
                "examples": {
                  "list": {
                    "summary": "Current build",
                    "value": {
                      "count": 2,
                      "approvals": [
                        {
                          "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
                          "status": "decided",
                          "action": "Refund $4,200 to customer #8831",
                          "amount": 4200,
                          "currency": "USD",
                          "channel_used": "whatsapp",
                          "created_at": "2026-10-01T12:00:02.000Z",
                          "expires_at": "2026-10-01T12:30:02.000Z"
                        },
                        {
                          "id": "3c0d55a9-e7f1-4b2a-9d6e-0f1a2b3c4d5e",
                          "status": "pending",
                          "action": "Delete the staging database",
                          "amount": null,
                          "currency": null,
                          "channel_used": null,
                          "created_at": "2026-10-01T12:10:41.000Z",
                          "expires_at": "2026-10-01T13:10:41.000Z"
                        }
                      ]
                    }
                  },
                  "paginated": {
                    "summary": "API build 2026-10: filters, cursor and decision on rows",
                    "value": {
                      "count": 1,
                      "approvals": [
                        {
                          "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
                          "status": "decided",
                          "action": "Refund $4,200 to customer #8831",
                          "amount": 4200,
                          "currency": "USD",
                          "channel_used": "whatsapp",
                          "created_at": "2026-10-01T12:00:02.000Z",
                          "expires_at": "2026-10-01T12:30:02.000Z",
                          "decision": {
                            "outcome": "approved",
                            "decided_via": "whatsapp_button",
                            "decided_by": "+14155550101",
                            "decided_at": "2026-10-01T12:04:11.000Z"
                          }
                        }
                      ],
                      "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0xMC0wMVQxMjowMDowMi4wMDBaIn0",
                      "has_more": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`insufficient_scope`: the API key lacks the scope this route needs (Phase C keys with scopes).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "value": {
                      "error": {
                        "code": "insufficient_scope",
                        "message": "This API key lacks the approvals:write scope.",
                        "details": {
                          "required": "approvals:write"
                        }
                      }
                    }
                  }
                }
              }
            },
            "x-approv-status": "phase-c"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/approvalId"
        }
      ],
      "get": {
        "tags": [
          "Approvals"
        ],
        "operationId": "getApproval",
        "summary": "Get an approval and its decision",
        "description": "Poll this endpoint for the outcome. `decision` is `null` until the approver replies; `status` moves `pending` → `delivered` → `decided` (or `expired` / `failed`). Reads are not rate-limited.",
        "responses": {
          "200": {
            "description": "The approval.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Approval"
                },
                "examples": {
                  "decided": {
                    "summary": "Approved with a WhatsApp button",
                    "value": {
                      "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
                      "status": "decided",
                      "action": "Refund $4,200 to customer #8831",
                      "amount": 4200,
                      "currency": "USD",
                      "params_hash": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00",
                      "channel_used": "whatsapp",
                      "locale": "en",
                      "expires_at": "2026-10-01T12:30:02.000Z",
                      "created_at": "2026-10-01T12:00:02.000Z",
                      "updated_at": "2026-10-01T12:04:11.000Z",
                      "decision": {
                        "outcome": "approved",
                        "decided_via": "whatsapp_button",
                        "decided_by": "+14155550101",
                        "decided_at": "2026-10-01T12:04:11.000Z"
                      }
                    }
                  },
                  "pending": {
                    "summary": "Still waiting for the approver",
                    "value": {
                      "id": "3c0d55a9-e7f1-4b2a-9d6e-0f1a2b3c4d5e",
                      "status": "delivered",
                      "action": "Delete the staging database",
                      "amount": null,
                      "currency": null,
                      "params_hash": null,
                      "channel_used": "sms",
                      "locale": "en",
                      "expires_at": "2026-10-01T13:10:41.000Z",
                      "created_at": "2026-10-01T12:10:41.000Z",
                      "updated_at": "2026-10-01T12:10:43.000Z",
                      "decision": null
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`insufficient_scope`: the API key lacks the scope this route needs (Phase C keys with scopes).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "value": {
                      "error": {
                        "code": "insufficient_scope",
                        "message": "This API key lacks the approvals:write scope.",
                        "details": {
                          "required": "approvals:write"
                        }
                      }
                    }
                  }
                }
              }
            },
            "x-approv-status": "phase-c"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/{id}/audit": {
      "parameters": [
        {
          "$ref": "#/components/parameters/approvalId"
        }
      ],
      "get": {
        "tags": [
          "Audit"
        ],
        "operationId": "getApprovalAudit",
        "summary": "Get the signed audit trail",
        "description": "Every state change of an approval is appended to a hash chain and signed with Ed25519. The response includes the server's own verification result, the public key, the hash formula and the field separator so you can re-verify the chain offline without trusting AI Approvel.\n\nPer event: `hash = sha256( prev_hash ␟ event_type ␟ canonical_json(payload) ␟ created_at )` where `␟` is U+241F (the `|` in `algorithm.hash_formula` is notation; `algorithm.field_separator` names the real byte), `prev_hash` of `seq 0` is 64 zeros, `canonical_json` sorts keys recursively with no whitespace, and the digest is lowercase hex. `signature` is base64 Ed25519 over the UTF-8 bytes of that hex string. A ready-made verifier is at https://ai-approvel.com/verify-audit.mjs.",
        "responses": {
          "200": {
            "description": "The audit trail.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuditTrail"
                },
                "examples": {
                  "verified": {
                    "summary": "Requested, sent, decided, webhook delivered",
                    "value": {
                      "approval_request_id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
                      "algorithm": {
                        "hash": "sha256",
                        "signature": "ed25519",
                        "hash_formula": "sha256( prev_hash | event_type | canonical_json(payload) | created_at )",
                        "field_separator": "U+241F (␟)"
                      },
                      "public_key": {
                        "key_id": "approv-2026-10",
                        "format": "spki-der-base64",
                        "key": "MCowBQYDK2VwAyEArryxoEQfSoTGSbFTbOxkOrscyYVy5Fjzqg4q/VavPlI="
                      },
                      "verification": {
                        "valid": true,
                        "event_count": 4,
                        "issues": []
                      },
                      "events": [
                        {
                          "seq": 0,
                          "event_type": "approval.requested",
                          "payload": {
                            "projectId": "284e2336-a2c5-44d2-9a23-ae5aa6da94cf",
                            "approverId": "bb07d4a7-22f6-4188-a139-66f16dc2b6e8",
                            "approverPhone": "+14155550101",
                            "action": "Refund $4,200 to customer #8831",
                            "amount": 4200,
                            "currency": "USD",
                            "context": {
                              "ticket": "ZD-4411"
                            },
                            "paramsHash": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00",
                            "locale": "en",
                            "timeoutSeconds": 1800,
                            "expiresAt": "2026-10-01T12:30:02.000Z",
                            "metadata": {}
                          },
                          "prev_hash": "0000000000000000000000000000000000000000000000000000000000000000",
                          "hash": "d58c7f38f0ec…",
                          "signature": "r4RxwdRHagb2…",
                          "public_key_id": "approv-2026-10",
                          "created_at": "2026-10-01T12:00:02.103Z"
                        },
                        {
                          "seq": 1,
                          "event_type": "message.sent",
                          "payload": {
                            "channel": "whatsapp",
                            "provider": "meta",
                            "providerMessageId": "wamid.HBgM…",
                            "attemptNo": 1
                          },
                          "prev_hash": "d58c7f38f0ec…",
                          "hash": "8d35531a58d9…",
                          "signature": "2ow5f1zGQsd5…",
                          "public_key_id": "approv-2026-10",
                          "created_at": "2026-10-01T12:00:04.418Z"
                        },
                        {
                          "seq": 2,
                          "event_type": "approval.decided",
                          "payload": {
                            "outcome": "approved",
                            "decidedVia": "whatsapp_button",
                            "decidedByPhone": "+14155550101",
                            "rawInput": "approve",
                            "decidedAt": "2026-10-01T12:04:11.000Z"
                          },
                          "prev_hash": "8d35531a58d9…",
                          "hash": "51728ba464f7…",
                          "signature": "5oM1pTMpN+ao…",
                          "public_key_id": "approv-2026-10",
                          "created_at": "2026-10-01T12:04:11.007Z"
                        },
                        {
                          "seq": 3,
                          "event_type": "webhook.delivered",
                          "payload": {
                            "callbackUrl": "https://agent.example.com/hooks/approv",
                            "statusCode": 200,
                            "outcome": "approved"
                          },
                          "prev_hash": "51728ba464f7…",
                          "hash": "80f4358459f9…",
                          "signature": "v85NN+cOLgc0…",
                          "public_key_id": "approv-2026-10",
                          "created_at": "2026-10-01T12:04:12.250Z"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`insufficient_scope`: the API key lacks the scope this route needs (Phase C keys with scopes).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "insufficient_scope": {
                    "value": {
                      "error": {
                        "code": "insufficient_scope",
                        "message": "This API key lacks the approvals:write scope.",
                        "details": {
                          "required": "approvals:write"
                        }
                      }
                    }
                  }
                }
              }
            },
            "x-approv-status": "phase-c"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/account/members": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "get": {
        "tags": [
          "Team"
        ],
        "operationId": "listMembers",
        "summary": "List workspace members",
        "description": "Every member of the caller's workspace, accepted or invited, plus `me` (the caller's row). Any role may read.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "Members.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MembersResponse"
                },
                "examples": {
                  "example": {
                    "value": {
                      "me": {
                        "id": "7c1f6d2e-0f3a-4c9b-8d2e-1a2b3c4d5e6f",
                        "role": "admin"
                      },
                      "members": [
                        {
                          "id": "7c1f6d2e-0f3a-4c9b-8d2e-1a2b3c4d5e6f",
                          "user_id": "0f6c9a2e-1234-4bcd-9abc-000000000001",
                          "email": "ada@example.com",
                          "role": "admin",
                          "status": "active",
                          "invited_at": "2026-09-01T09:00:00.000Z",
                          "accepted_at": "2026-09-01T09:12:40.000Z",
                          "last_seen_at": "2026-10-02T08:15:00.000Z"
                        },
                        {
                          "id": "9b8a7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
                          "user_id": null,
                          "email": "grace@example.com",
                          "role": "member",
                          "status": "invited",
                          "invited_at": "2026-10-02T09:00:00.000Z",
                          "accepted_at": null,
                          "last_seen_at": null
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/account/members/invite": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "post": {
        "tags": [
          "Team"
        ],
        "operationId": "inviteMember",
        "summary": "Invite someone by email",
        "description": "Owner or admin. Creates an `invited` row and emails the link when `RESEND_API_KEY` is set. Re-inviting an invited address refreshes its token and expiry (the old link stops working). Inviting an active member → 409 `conflict`.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "201": {
            "description": "Invited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InviteResponse"
                },
                "examples": {
                  "example": {
                    "value": {
                      "member": {
                        "id": "9b8a7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
                        "user_id": null,
                        "email": "grace@example.com",
                        "role": "member",
                        "status": "invited",
                        "invited_at": "2026-10-02T09:00:00.000Z",
                        "accepted_at": null,
                        "last_seen_at": null
                      },
                      "invite_url": "https://ai-approvel.com/invite/9f2c1d8e7b6a5f4e3d2c1b0a"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InviteRequest"
              }
            }
          }
        }
      }
    },
    "/account/members/{id}": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "parameters": [
        {
          "$ref": "#/components/parameters/memberId"
        }
      ],
      "patch": {
        "tags": [
          "Team"
        ],
        "operationId": "updateMemberRole",
        "summary": "Change a member's role",
        "description": "Owner or admin; only an owner may grant or remove `owner`. Demoting the only owner → 409 `last_owner`.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Member"
                },
                "examples": {
                  "example": {
                    "value": {
                      "member": {
                        "id": "7c1f6d2e-0f3a-4c9b-8d2e-1a2b3c4d5e6f",
                        "user_id": "0f6c9a2e-1234-4bcd-9abc-000000000001",
                        "email": "ada@example.com",
                        "role": "member",
                        "status": "active",
                        "invited_at": "2026-09-01T09:00:00.000Z",
                        "accepted_at": "2026-09-01T09:12:40.000Z",
                        "last_seen_at": "2026-10-02T08:15:00.000Z"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateMemberRequest"
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "Team"
        ],
        "operationId": "removeMember",
        "summary": "Remove a member or revoke an invite",
        "description": "Owner or admin, or the member themselves (leaving). Removing the only owner → 409 `last_owner`.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "Removed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "deleted"
                  ],
                  "properties": {
                    "deleted": {
                      "const": true
                    }
                  }
                },
                "example": {
                  "deleted": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/account/invites/{token}": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "parameters": [
        {
          "$ref": "#/components/parameters/inviteToken"
        }
      ],
      "get": {
        "tags": [
          "Team"
        ],
        "operationId": "getInvite",
        "summary": "Read an invite",
        "description": "Any signed-in user. What `/invite/<token>` shows before accepting.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "The invite.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InviteInfo"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "410": {
            "$ref": "#/components/responses/InviteExpired"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/account/invites/{token}/accept": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "parameters": [
        {
          "$ref": "#/components/parameters/inviteToken"
        }
      ],
      "post": {
        "tags": [
          "Team"
        ],
        "operationId": "acceptInvite",
        "summary": "Accept an invite",
        "description": "The signed-in email must equal the invited one (case-insensitive) → else 403 `invite_mismatch`. A user who already belongs to another workspace gets 409 `conflict` (\"Leave your current workspace first\"). A user with no workspace joins it; `POST /account/bootstrap` then resolves to the joined workspace.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "Joined.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AcceptInviteResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "410": {
            "$ref": "#/components/responses/InviteExpired"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/account/keys": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "get": {
        "tags": [
          "API keys"
        ],
        "operationId": "listApiKeys",
        "summary": "List API keys",
        "description": "Owner or admin. Revoked keys are included for 30 days with `revoked_at` set.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "Keys.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiKeyList"
                },
                "examples": {
                  "example": {
                    "value": {
                      "keys": [
                        {
                          "id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
                          "name": "Production agent",
                          "prefix": "apk_live_1a2b3c4d",
                          "scopes": [
                            "approvals:write"
                          ],
                          "created_at": "2026-10-02T09:30:00.000Z",
                          "created_by_email": "ada@example.com",
                          "last_used_at": "2026-10-02T10:02:11.000Z",
                          "expires_at": "2026-12-31T09:30:00.000Z",
                          "revoked_at": null
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "tags": [
          "API keys"
        ],
        "operationId": "createApiKey",
        "summary": "Create a named API key",
        "description": "Owner or admin. The full key is in `api_key`, shown once. At most 20 active keys per project → 409 `conflict`.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateApiKeyResponse"
                },
                "examples": {
                  "example": {
                    "value": {
                      "key": {
                        "id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
                        "name": "Production agent",
                        "prefix": "apk_live_1a2b3c4d",
                        "scopes": [
                          "approvals:write"
                        ],
                        "created_at": "2026-10-02T09:30:00.000Z",
                        "created_by_email": "ada@example.com",
                        "last_used_at": "2026-10-02T10:02:11.000Z",
                        "expires_at": "2026-12-31T09:30:00.000Z",
                        "revoked_at": null
                      },
                      "api_key": "apk_live_1a2b3c4d_u8Zq3mT2vLw9xPq4rS6tYbNc"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateApiKeyRequest"
              }
            }
          }
        }
      }
    },
    "/account/keys/{id}": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "parameters": [
        {
          "$ref": "#/components/parameters/keyId"
        }
      ],
      "delete": {
        "tags": [
          "API keys"
        ],
        "operationId": "revokeApiKey",
        "summary": "Revoke an API key",
        "description": "Owner or admin. Requests with the key get 401 immediately. The legacy `POST /account/rotate-key` still revokes every active key and mints a `Default` one with both scopes.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "Revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RevokeApiKeyResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/webhooks/endpoints": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "get": {
        "tags": [
          "Webhook endpoints"
        ],
        "operationId": "listWebhookEndpoints",
        "summary": "List webhook endpoints",
        "description": "Owner or admin.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "Endpoints.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookEndpointList"
                },
                "examples": {
                  "example": {
                    "value": {
                      "endpoints": [
                        {
                          "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
                          "url": "https://hooks.example.com/approv",
                          "description": "Refund agent (prod)",
                          "events": [
                            "approval.decided"
                          ],
                          "enabled": true,
                          "secret_prefix": "whsec_e2e00",
                          "created_at": "2026-09-12T08:00:00.000Z",
                          "last_delivery_at": "2026-10-02T10:05:00.000Z",
                          "last_status": "succeeded"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "tags": [
          "Webhook endpoints"
        ],
        "operationId": "createWebhookEndpoint",
        "summary": "Add a webhook endpoint",
        "description": "Owner or admin. The URL must pass the same SSRF check as `callback_url`. `secret` is shown once. At most 10 endpoints → 409 `conflict`. Enabled endpoints subscribed to `approval.decided` receive every decision, signed with their own secret, with the same headers as the legacy webhook plus `X-Approv-Delivery: <id>`.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateWebhookEndpointResponse"
                },
                "examples": {
                  "example": {
                    "value": {
                      "endpoint": {
                        "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
                        "url": "https://hooks.example.com/approv",
                        "description": "Refund agent (prod)",
                        "events": [
                          "approval.decided"
                        ],
                        "enabled": true,
                        "secret_prefix": "whsec_e2e00",
                        "created_at": "2026-09-12T08:00:00.000Z",
                        "last_delivery_at": "2026-10-02T10:05:00.000Z",
                        "last_status": "succeeded"
                      },
                      "secret": "whsec_e2e0000000000000000000000000000000000000AAA"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateWebhookEndpointRequest"
              }
            }
          }
        }
      }
    },
    "/webhooks/endpoints/{id}": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "parameters": [
        {
          "$ref": "#/components/parameters/endpointId"
        }
      ],
      "patch": {
        "tags": [
          "Webhook endpoints"
        ],
        "operationId": "updateWebhookEndpoint",
        "summary": "Edit, enable or disable an endpoint",
        "description": "Owner or admin. Partial update; the secret is unchanged.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookEndpoint"
                },
                "examples": {
                  "example": {
                    "value": {
                      "endpoint": {
                        "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
                        "url": "https://hooks.example.com/approv",
                        "description": "Refund agent (prod)",
                        "events": [
                          "approval.decided"
                        ],
                        "enabled": false,
                        "secret_prefix": "whsec_e2e00",
                        "created_at": "2026-09-12T08:00:00.000Z",
                        "last_delivery_at": "2026-10-02T10:05:00.000Z",
                        "last_status": "succeeded"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateWebhookEndpointRequest"
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "Webhook endpoints"
        ],
        "operationId": "deleteWebhookEndpoint",
        "summary": "Delete an endpoint",
        "description": "Owner or admin. Past deliveries stay in the log.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "deleted"
                  ],
                  "properties": {
                    "deleted": {
                      "const": true
                    }
                  }
                },
                "example": {
                  "deleted": true
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/webhooks/endpoints/{id}/rotate-secret": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "parameters": [
        {
          "$ref": "#/components/parameters/endpointId"
        }
      ],
      "post": {
        "tags": [
          "Webhook endpoints"
        ],
        "operationId": "rotateWebhookEndpointSecret",
        "summary": "Rotate an endpoint's signing secret",
        "description": "Owner or admin. Deliveries are signed with the new secret from now on; the old one stops verifying immediately.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "The new secret, shown once.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSecretResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/webhooks/endpoints/{id}/test": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "parameters": [
        {
          "$ref": "#/components/parameters/endpointId"
        }
      ],
      "post": {
        "tags": [
          "Webhook endpoints"
        ],
        "operationId": "testWebhookEndpoint",
        "summary": "Send a signed test event",
        "description": "Owner or admin. Sends a `webhook.test` event and records the delivery.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "The test delivery.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookDelivery"
                },
                "examples": {
                  "example": {
                    "value": {
                      "delivery": {
                        "id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
                        "endpoint_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
                        "approval_request_id": null,
                        "event": "webhook.test",
                        "status": "succeeded",
                        "attempts": 1,
                        "last_attempt_at": "2026-10-02T10:11:00.000Z",
                        "response_status": 200,
                        "error": null,
                        "created_at": "2026-10-02T10:05:00.000Z"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/webhooks/deliveries": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "get": {
        "tags": [
          "Webhook endpoints"
        ],
        "operationId": "listWebhookDeliveries",
        "summary": "List deliveries",
        "description": "Owner or admin. Newest first with keyset pagination (`cursor` / `next_cursor` / `has_more`, like `GET /`). Rows omit `request_body`.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "A page of deliveries.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookDeliveryList"
                },
                "examples": {
                  "example": {
                    "value": {
                      "deliveries": [
                        {
                          "id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
                          "endpoint_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
                          "approval_request_id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
                          "event": "approval.decided",
                          "status": "failed",
                          "attempts": 5,
                          "last_attempt_at": "2026-10-02T10:11:00.000Z",
                          "response_status": 503,
                          "error": "upstream returned 503 Service Unavailable",
                          "created_at": "2026-10-02T10:05:00.000Z"
                        }
                      ],
                      "next_cursor": null,
                      "has_more": false
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "parameters": [
          {
            "$ref": "#/components/parameters/limit"
          },
          {
            "$ref": "#/components/parameters/deliveryEndpointId"
          },
          {
            "$ref": "#/components/parameters/deliveryApprovalId"
          },
          {
            "$ref": "#/components/parameters/deliveryStatus"
          },
          {
            "$ref": "#/components/parameters/cursor"
          }
        ]
      }
    },
    "/webhooks/deliveries/{id}": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "parameters": [
        {
          "$ref": "#/components/parameters/deliveryId"
        }
      ],
      "get": {
        "tags": [
          "Webhook endpoints"
        ],
        "operationId": "getWebhookDelivery",
        "summary": "Get a delivery",
        "description": "Owner or admin. Includes `request_body` (the signed bytes) and `response_status`.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "The delivery.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookDelivery"
                },
                "examples": {
                  "example": {
                    "value": {
                      "delivery": {
                        "id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
                        "endpoint_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
                        "approval_request_id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
                        "event": "approval.decided",
                        "status": "failed",
                        "attempts": 5,
                        "last_attempt_at": "2026-10-02T10:11:00.000Z",
                        "response_status": 503,
                        "error": "upstream returned 503 Service Unavailable",
                        "created_at": "2026-10-02T10:05:00.000Z",
                        "request_body": "{\"id\":\"ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f\",\"status\":\"decided\",\"outcome\":\"approved\",\"decided_via\":\"whatsapp_button\",\"decided_at\":\"2026-10-02T10:04:11.000Z\",\"action\":\"Refund $4,200 to customer #8831\",\"params_hash\":null,\"approver_id\":\"bb07d4a7-22f6-4188-a139-66f16dc2b6e8\",\"amount\":4200,\"currency\":\"USD\"}"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/webhooks/deliveries/{id}/replay": {
      "servers": [
        {
          "url": "https://{project}.supabase.co/functions/v1",
          "description": "The Supabase functions host. The dashboard routes below are not under /approvals: they are the `account` and `webhooks` functions.",
          "variables": {
            "project": {
              "default": "your-project-ref",
              "description": "The Supabase project ref of your AI Approvel deployment."
            }
          }
        }
      ],
      "x-approv-status": "phase-c",
      "parameters": [
        {
          "$ref": "#/components/parameters/deliveryId"
        }
      ],
      "post": {
        "tags": [
          "Webhook endpoints"
        ],
        "operationId": "replayWebhookDelivery",
        "summary": "Replay a delivery",
        "description": "Owner or admin. Creates a new `pending` delivery with the same body, signed with the endpoint's current secret, and enqueues it with the usual retries.",
        "security": [
          {
            "userSession": []
          }
        ],
        "x-approv-status": "phase-c",
        "responses": {
          "200": {
            "description": "The new delivery row.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookDelivery"
                },
                "examples": {
                  "example": {
                    "value": {
                      "delivery": {
                        "id": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a",
                        "endpoint_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
                        "approval_request_id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
                        "event": "approval.decided",
                        "status": "pending",
                        "attempts": 0,
                        "last_attempt_at": null,
                        "response_status": null,
                        "error": null,
                        "created_at": "2026-10-02T10:05:00.000Z"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/UnauthorizedSession"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    }
  },
  "webhooks": {
    "approval.decided": {
      "post": {
        "operationId": "approvalDecidedWebhook",
        "summary": "Decision callback (sent to your callback_url)",
        "description": "When an approver decides, AI Approvel POSTs a JSON body — `{ id, status, outcome, decided_via, decided_at, action, params_hash, approver_id, amount, currency }`, in exactly that field order, since you verify over the raw bytes — to the `callback_url` you passed at creation. New fields may be appended; existing ones never change meaning. The request is signed so you can prove it came from us:\n\n- `X-Approv-Event` — always `approval.decided`.\n- `X-Approv-Timestamp` — Unix time (seconds) when the request was signed.\n- `X-Approv-Signature` — lowercase hex HMAC-SHA256 over the string `${timestamp}.${rawBody}` keyed with your webhook signing secret.\n\nTo verify: recompute the HMAC over the **raw** request body (not a re-serialised object), compare with a constant-time comparison, and reject the request if `|now − timestamp| > 300` seconds (the tolerance the official SDK's `verifyWebhook` applies). Respond with any 2xx within 10 seconds. A non-2xx response, a redirect or a timeout counts as a failure: the delivery is retried by the queue (about a minute apart, up to 5 attempts, then parked) and every attempt is recorded in the audit trail as `webhook.delivered` or `webhook.failed`. Make your handler idempotent on `id`.\n\n**Signing secret.** Each project has its own `whsec_…` secret: read it in Settings → Webhooks or with `GET /account/webhook-secret` (your user session, not the API key), rotate it with `POST /account/webhook-secret/rotate`. Rotation takes effect on the next delivery and the old secret is not honoured in parallel — update your verifier first. Projects created before per-project secrets existed are still signed with the deployment-wide secret until one is provisioned (the dashboard provisions it on first visit).\n\n`callback_url` must be a public `https://` host — private, loopback, link-local and cloud-metadata addresses are rejected at creation and again at send time (the send-time refusal is recorded as `webhook.failed` with `errorCode: \"unsafe_callback_url\"` and is not retried).",
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/xApprovEvent"
          },
          {
            "$ref": "#/components/parameters/xApprovTimestamp"
          },
          {
            "$ref": "#/components/parameters/xApprovSignature"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ApprovalDecidedEvent"
              },
              "examples": {
                "approved": {
                  "value": {
                    "id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
                    "status": "decided",
                    "outcome": "approved",
                    "decided_via": "whatsapp_button",
                    "decided_at": "2026-10-01T12:04:11.000Z",
                    "action": "Refund $4,200 to customer #8831",
                    "params_hash": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00",
                    "approver_id": "bb07d4a7-22f6-4188-a139-66f16dc2b6e8",
                    "amount": 4200,
                    "currency": "USD"
                  }
                },
                "rejected": {
                  "value": {
                    "id": "3c0d55a9-e7f1-4b2a-9d6e-0f1a2b3c4d5e",
                    "status": "decided",
                    "outcome": "rejected",
                    "decided_via": "sms_reply",
                    "decided_at": "2026-10-01T12:14:09.000Z",
                    "action": "Delete the staging database",
                    "params_hash": null,
                    "approver_id": "bb07d4a7-22f6-4188-a139-66f16dc2b6e8",
                    "amount": null,
                    "currency": null
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Return any 2xx to acknowledge receipt. The body is ignored."
          },
          "401": {
            "description": "Your endpoint rejected the signature. AI Approvel treats this like any non-2xx and retries from the queue."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "API key: apk_live_<8 hex>_<secret>",
        "description": "Your project API key, created and rotated in Settings. Format `apk_live_<8 hex>_<secret>`; the non-secret `apk_live_<8 hex>` prefix is what the dashboard shows to tell keys apart. Sent as `Authorization: Bearer <key>`. A missing, malformed, rotated or unknown key yields `401 unauthorized`."
      },
      "userSession": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "Supabase JWT",
        "description": "The signed-in dashboard user's Supabase access token, for the `/account/*` and `/webhooks/*` routes. Never an API key. Role checks apply: owner, admin or member (see the Role schema).",
        "x-approv-status": "phase-c"
      }
    },
    "parameters": {
      "approvalId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The approval id (UUID) returned by `POST /`.",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f"
      },
      "idempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Any string that is unique per approval you intend to create, e.g. your own job id. Repeating a `POST /` with the same key returns the original approval with `replayed: true` instead of creating (and messaging) a second one. Scoped to your project; no expiry.",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 255
        },
        "example": "refund-8831-attempt-1"
      },
      "limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "description": "Maximum number of approvals to return, newest first. Default 50, capped at 200. The dashboard asks for 50 and grows by 50 on \"Load more\".",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 200,
          "default": 50
        },
        "example": 50
      },
      "status": {
        "name": "status",
        "in": "query",
        "required": false,
        "description": "Return only approvals in this status. `approved` / `rejected` mean *decided with that outcome*. Anything else is a `400 validation_error`.",
        "schema": {
          "type": "string",
          "enum": [
            "pending",
            "delivered",
            "decided",
            "approved",
            "rejected",
            "expired",
            "failed"
          ]
        },
        "example": "approved"
      },
      "q": {
        "name": "q",
        "in": "query",
        "required": false,
        "description": "Case-insensitive substring match on `action`, at most 200 characters. `%`, `_` and `\\` match literally.",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 200
        },
        "example": "refund"
      },
      "cursor": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "description": "Opaque `next_cursor` from the previous page; returns the approvals older than it. Encodes `(created_at, id)` — anything the server did not mint is a `400 validation_error`. Keep `status` / `q` the same across pages.",
        "schema": {
          "type": "string"
        },
        "example": "WyIyMDI2LTEwLTAxVDEyOjAwOjAyLjAwMFoiLCJmZjViYjNiZS03M2VhLTRjYzYtOTJhZS1mZDFjYzYwNmJkMmYiXQ"
      },
      "xApprovEvent": {
        "name": "X-Approv-Event",
        "in": "header",
        "required": true,
        "description": "The event type. Always `approval.decided` today.",
        "schema": {
          "type": "string",
          "const": "approval.decided"
        },
        "example": "approval.decided"
      },
      "xApprovTimestamp": {
        "name": "X-Approv-Timestamp",
        "in": "header",
        "required": true,
        "description": "Unix timestamp in seconds at which the webhook was signed. Reject deliveries more than 300 seconds from your clock.",
        "schema": {
          "type": "string",
          "pattern": "^[0-9]+$"
        },
        "example": "1790251451"
      },
      "xApprovSignature": {
        "name": "X-Approv-Signature",
        "in": "header",
        "required": true,
        "description": "Lowercase hex HMAC-SHA256 of `${X-Approv-Timestamp}.${rawBody}` keyed with your webhook signing secret.",
        "schema": {
          "type": "string",
          "pattern": "^[0-9a-f]{64}$"
        },
        "example": "5f1d3c8e2a7b4d6f9e0c1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"
      },
      "memberId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The member row id.",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "7c1f6d2e-0f3a-4c9b-8d2e-1a2b3c4d5e6f",
        "x-approv-status": "phase-c"
      },
      "inviteToken": {
        "name": "token",
        "in": "path",
        "required": true,
        "description": "The invite token from the invite link.",
        "schema": {
          "type": "string"
        },
        "example": "9f2c1d8e7b6a5f4e3d2c1b0a",
        "x-approv-status": "phase-c"
      },
      "keyId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The API key id.",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
        "x-approv-status": "phase-c"
      },
      "endpointId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The webhook endpoint id.",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
        "x-approv-status": "phase-c"
      },
      "deliveryId": {
        "name": "id",
        "in": "path",
        "required": true,
        "description": "The delivery id (also sent as `X-Approv-Delivery`).",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
        "x-approv-status": "phase-c"
      },
      "deliveryEndpointId": {
        "name": "endpoint_id",
        "in": "query",
        "required": false,
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "description": "Only deliveries to this endpoint.",
        "example": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
        "x-approv-status": "phase-c"
      },
      "deliveryApprovalId": {
        "name": "approval_id",
        "in": "query",
        "required": false,
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "description": "Only deliveries for this approval.",
        "example": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
        "x-approv-status": "phase-c"
      },
      "deliveryStatus": {
        "name": "status",
        "in": "query",
        "required": false,
        "schema": {
          "$ref": "#/components/schemas/DeliveryStatus"
        },
        "example": "failed",
        "x-approv-status": "phase-c"
      }
    },
    "headers": {
      "RetryAfter": {
        "description": "Seconds until the current fixed one-minute window rolls over. Wait at least this long before retrying.",
        "schema": {
          "type": "integer",
          "minimum": 1
        },
        "example": 17
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing, malformed, unknown or rotated API key. The code is always `unauthorized`; the message says which.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "missing": {
                "value": {
                  "error": {
                    "code": "unauthorized",
                    "message": "Provide an API key via `Authorization: Bearer <key>`.",
                    "details": null
                  }
                }
              },
              "malformed": {
                "value": {
                  "error": {
                    "code": "unauthorized",
                    "message": "Malformed API key.",
                    "details": null
                  }
                }
              },
              "unknownOrRotated": {
                "value": {
                  "error": {
                    "code": "unauthorized",
                    "message": "Missing or invalid API key.",
                    "details": null
                  }
                }
              }
            }
          }
        }
      },
      "ValidationError": {
        "description": "The request body failed validation, the approver is unknown to this project, or the approver has not confirmed their opt-in yet. `details` is the Zod `flatten()` output for body errors (`{ formErrors, fieldErrors }`) or `{ approver_id }` for approver errors.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "body": {
                "summary": "Field errors",
                "value": {
                  "error": {
                    "code": "validation_error",
                    "message": "Invalid request body.",
                    "details": {
                      "formErrors": [],
                      "fieldErrors": {
                        "action": [
                          "String must contain at least 1 character(s)"
                        ],
                        "approver_id": [
                          "Invalid uuid"
                        ],
                        "timeout_seconds": [
                          "Expected integer, received float"
                        ]
                      }
                    }
                  }
                }
              },
              "unknownField": {
                "summary": "Unknown fields are rejected (strict body)",
                "value": {
                  "error": {
                    "code": "validation_error",
                    "message": "Invalid request body.",
                    "details": {
                      "formErrors": [
                        "Unrecognized key(s) in object: 'expires_in_seconds'"
                      ],
                      "fieldErrors": {}
                    }
                  }
                }
              },
              "unknownApprover": {
                "value": {
                  "error": {
                    "code": "validation_error",
                    "message": "Unknown approver_id for this project.",
                    "details": {
                      "approver_id": "bb07d4a7-22f6-4188-a139-66f16dc2b6e8"
                    }
                  }
                }
              },
              "notOptedIn": {
                "value": {
                  "error": {
                    "code": "validation_error",
                    "message": "This approver has not confirmed their opt-in yet.",
                    "details": {
                      "approver_id": "bb07d4a7-22f6-4188-a139-66f16dc2b6e8"
                    }
                  }
                }
              },
              "badCallback": {
                "value": {
                  "error": {
                    "code": "validation_error",
                    "message": "callback_url must use https and resolve to a public host.",
                    "details": null
                  }
                }
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "No approval with that id in this project (ids from other projects also 404).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "notFound": {
                "value": {
                  "error": {
                    "code": "not_found",
                    "message": "Approval not found.",
                    "details": null
                  }
                }
              }
            }
          }
        }
      },
      "QuotaExceeded": {
        "description": "The project's monthly approval quota for its plan is used up (Free 100, Growth 5 000, Scale unlimited). `details` is `{ plan, limit, used, period }` with `period` the UTC month as `YYYY-MM`. A billing condition, not a transient one: there is no `Retry-After` — upgrade in Settings → Plan & billing. Only genuinely new, valid requests are charged; idempotent replays and rejected bodies are free.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "quota": {
                "value": {
                  "error": {
                    "code": "quota_exceeded",
                    "message": "Monthly plan limit of 100 approvals reached on the Free plan. Upgrade your plan in the dashboard (Billing) to send more this month.",
                    "details": {
                      "plan": "free",
                      "limit": 100,
                      "used": 100,
                      "period": "2026-10"
                    }
                  }
                }
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many `POST /` requests in the current fixed one-minute window (default 60 per project, counted before validation). Wait for `Retry-After` seconds (or until the next minute) and retry.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "rateLimited": {
                "value": {
                  "error": {
                    "code": "rate_limited",
                    "message": "Rate limit of 60 approvals/minute exceeded. Try again in 17s.",
                    "details": null
                  }
                }
              }
            }
          }
        }
      },
      "InternalError": {
        "description": "Unexpected server error. Nothing about the request is leaked; retry with the same `Idempotency-Key`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "internal": {
                "value": {
                  "error": {
                    "code": "internal_error",
                    "message": "Something went wrong."
                  }
                }
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "The signed-in user's role does not allow this (`forbidden`), or the invite was sent to another address (`invite_mismatch`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "forbidden": {
                "value": {
                  "error": {
                    "code": "forbidden",
                    "message": "Your role does not allow this action.",
                    "details": null
                  }
                }
              },
              "invite_mismatch": {
                "value": {
                  "error": {
                    "code": "invite_mismatch",
                    "message": "This invite was sent to a different email address.",
                    "details": null
                  }
                }
              }
            }
          }
        },
        "x-approv-status": "phase-c"
      },
      "Conflict": {
        "description": "State clash: `conflict` (already a member, 20 active keys, 10 endpoints, user already in a workspace) or `last_owner` (the only owner cannot be removed or demoted).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "conflict": {
                "value": {
                  "error": {
                    "code": "conflict",
                    "message": "Leave your current workspace first.",
                    "details": null
                  }
                }
              },
              "last_owner": {
                "value": {
                  "error": {
                    "code": "last_owner",
                    "message": "This is the last owner of the workspace.",
                    "details": null
                  }
                }
              }
            }
          }
        },
        "x-approv-status": "phase-c"
      },
      "InviteExpired": {
        "description": "The invite token is older than 7 days.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "invite_expired": {
                "value": {
                  "error": {
                    "code": "invite_expired",
                    "message": "This invite has expired.",
                    "details": null
                  }
                }
              }
            }
          }
        },
        "x-approv-status": "phase-c"
      },
      "UnauthorizedSession": {
        "description": "Missing or expired Supabase session token.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "missing": {
                "value": {
                  "error": {
                    "code": "unauthorized",
                    "message": "Invalid or missing user token",
                    "details": null
                  }
                }
              }
            }
          }
        },
        "x-approv-status": "phase-c"
      }
    },
    "schemas": {
      "ApprovalStatus": {
        "type": "string",
        "description": "Lifecycle of an approval. `pending` → `delivered` once the message was handed to the carrier for the approver → `decided` when they replied; `expired` when the window lapsed without a reply; `failed` when delivery could not be completed.",
        "enum": [
          "pending",
          "delivered",
          "decided",
          "expired",
          "failed"
        ]
      },
      "DecisionOutcome": {
        "type": "string",
        "enum": [
          "approved",
          "rejected"
        ]
      },
      "Channel": {
        "type": "string",
        "description": "Messaging channel used to reach the approver. `console` only appears on development deployments that log instead of sending.",
        "enum": [
          "whatsapp",
          "sms",
          "console"
        ]
      },
      "DecidedVia": {
        "type": "string",
        "description": "How the decision arrived: a WhatsApp interactive button, a WhatsApp text reply, an SMS reply, the signed web link in the message, the development console, or the API.",
        "enum": [
          "whatsapp_button",
          "whatsapp_text",
          "sms_reply",
          "web_link",
          "console",
          "api"
        ]
      },
      "Locale": {
        "type": "string",
        "description": "Language of the message sent to the approver.",
        "enum": [
          "en",
          "ar"
        ],
        "default": "en"
      },
      "CreateApprovalRequest": {
        "type": "object",
        "required": [
          "approver_id",
          "action"
        ],
        "additionalProperties": false,
        "description": "Strict: unknown fields are rejected with `400 validation_error`.",
        "properties": {
          "action": {
            "type": "string",
            "minLength": 1,
            "maxLength": 500,
            "description": "Human-readable description of what the agent wants to do. This is the text the approver sees."
          },
          "approver_id": {
            "type": "string",
            "format": "uuid",
            "description": "Id of the approver (from the Approvers page). Must belong to your project and have confirmed their phone number."
          },
          "amount": {
            "type": "number",
            "minimum": 0,
            "description": "Money at stake, if any (finite, non-negative). Shown to the approver alongside `currency`."
          },
          "currency": {
            "type": "string",
            "minLength": 3,
            "maxLength": 3,
            "description": "ISO 4217 code, exactly three letters; upper-cased on input (`usd` → `USD`).",
            "examples": [
              "USD",
              "EUR",
              "SAR"
            ]
          },
          "context": {
            "type": "object",
            "additionalProperties": true,
            "description": "Arbitrary JSON stored with the approval and written into the `approval.requested` audit event. Not shown on the phone and not included in the webhook."
          },
          "params_hash": {
            "type": "string",
            "minLength": 1,
            "maxLength": 128,
            "description": "Hash (SHA-256 hex by convention) of the exact action parameters this approval is bound to. Returned on `GET /{id}` so you can refuse to execute if the parameters changed after approval."
          },
          "callback_url": {
            "type": "string",
            "format": "uri",
            "pattern": "^https://",
            "description": "Webhook to POST the decision to. Must be `https://` and resolve to a public host (no private, loopback, link-local or cloud-metadata addresses)."
          },
          "locale": {
            "$ref": "#/components/schemas/Locale"
          },
          "timeout_seconds": {
            "type": "integer",
            "minimum": 1,
            "default": 3600,
            "description": "How long the approver has to reply before the approval expires. Positive integer; default 3600 (1 hour); values above the deployment maximum (86400 = 24 hours by default) are clamped to it. Treat `expired` as not approved."
          },
          "metadata": {
            "type": "object",
            "additionalProperties": true,
            "description": "Arbitrary JSON written into the `approval.requested` audit event. Not stored on the approval record itself."
          }
        }
      },
      "CreateApprovalResponse": {
        "type": "object",
        "required": [
          "id",
          "status",
          "expires_at",
          "replayed"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "Approval id. Store it; it is the key for status, audit and webhook correlation."
          },
          "status": {
            "$ref": "#/components/schemas/ApprovalStatus"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "`created_at + timeout_seconds`."
          },
          "replayed": {
            "type": "boolean",
            "description": "`true` when the `Idempotency-Key` matched an existing approval and that one was returned instead of a new one being created."
          }
        }
      },
      "Approval": {
        "type": "object",
        "description": "Full record returned by `GET /{id}`. Unknown additional fields may appear in later builds; ignore what you do not know.",
        "required": [
          "id",
          "status",
          "action",
          "amount",
          "currency",
          "params_hash",
          "channel_used",
          "locale",
          "expires_at",
          "created_at",
          "updated_at",
          "decision"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "$ref": "#/components/schemas/ApprovalStatus"
          },
          "action": {
            "type": "string"
          },
          "amount": {
            "type": [
              "number",
              "null"
            ]
          },
          "currency": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO 4217 code."
          },
          "params_hash": {
            "type": [
              "string",
              "null"
            ],
            "description": "The `params_hash` you sent, or `null`."
          },
          "channel_used": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Channel"
              },
              {
                "type": "null"
              }
            ],
            "description": "Channel the request was delivered on; `null` until delivered."
          },
          "locale": {
            "$ref": "#/components/schemas/Locale"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "decision": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Decision"
              },
              {
                "type": "null"
              }
            ],
            "description": "`null` until the approver replies."
          }
        }
      },
      "ApprovalListItem": {
        "type": "object",
        "description": "Summary row returned by `GET /`.",
        "required": [
          "id",
          "status",
          "action",
          "amount",
          "currency",
          "channel_used",
          "created_at",
          "expires_at",
          "decision"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "$ref": "#/components/schemas/ApprovalStatus"
          },
          "action": {
            "type": "string"
          },
          "amount": {
            "type": [
              "number",
              "null"
            ]
          },
          "currency": {
            "type": [
              "string",
              "null"
            ]
          },
          "channel_used": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Channel"
              },
              {
                "type": "null"
              }
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "decision": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ListDecision"
              },
              {
                "type": "null"
              }
            ],
            "description": "`null` until the approver replies. The list row omits `decided_by`; fetch `GET /{id}` for it."
          }
        }
      },
      "ApprovalList": {
        "type": "object",
        "required": [
          "count",
          "approvals",
          "next_cursor",
          "has_more"
        ],
        "properties": {
          "count": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of rows in `approvals` (this page), not the project total."
          },
          "approvals": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ApprovalListItem"
            }
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Pass as `?cursor=` to fetch the next, older page; `null` on the last page. Opaque — encodes `(created_at, id)` of the last row."
          },
          "has_more": {
            "type": "boolean",
            "description": "`true` when another page exists (and `next_cursor` is set)."
          }
        }
      },
      "ListDecision": {
        "type": "object",
        "description": "The decision as `GET /` rows carry it: `Decision` without `decided_by`.",
        "required": [
          "outcome",
          "decided_at",
          "decided_via"
        ],
        "properties": {
          "outcome": {
            "$ref": "#/components/schemas/DecisionOutcome"
          },
          "decided_at": {
            "type": "string",
            "format": "date-time"
          },
          "decided_via": {
            "$ref": "#/components/schemas/DecidedVia"
          }
        }
      },
      "Decision": {
        "type": "object",
        "required": [
          "outcome",
          "decided_via",
          "decided_by",
          "decided_at"
        ],
        "properties": {
          "outcome": {
            "$ref": "#/components/schemas/DecisionOutcome"
          },
          "decided_via": {
            "$ref": "#/components/schemas/DecidedVia"
          },
          "decided_by": {
            "type": "string",
            "description": "E.164 phone number that supplied the decision, verbatim (evidence). Treat as personal data."
          },
          "decided_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "AuditVerificationIssue": {
        "type": "object",
        "required": [
          "seq",
          "reason"
        ],
        "properties": {
          "seq": {
            "type": "integer",
            "minimum": 0
          },
          "reason": {
            "type": "string"
          }
        }
      },
      "AuditVerification": {
        "type": "object",
        "required": [
          "valid",
          "event_count",
          "issues"
        ],
        "properties": {
          "valid": {
            "type": "boolean",
            "description": "Server-side verification of the hash chain and every signature."
          },
          "event_count": {
            "type": "integer",
            "minimum": 0
          },
          "issues": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AuditVerificationIssue"
            },
            "description": "Empty when `valid` is true."
          }
        }
      },
      "AuditAlgorithm": {
        "type": "object",
        "required": [
          "hash",
          "signature",
          "hash_formula",
          "field_separator"
        ],
        "properties": {
          "hash": {
            "type": "string",
            "const": "sha256"
          },
          "signature": {
            "type": "string",
            "const": "ed25519"
          },
          "hash_formula": {
            "type": "string",
            "description": "The formula used to compute each event's `hash`. The `|` is notation for \"joined with `field_separator`\"; `canonical_json` is JSON with keys sorted recursively and no whitespace; the digest is lowercase hex.",
            "const": "sha256( prev_hash | event_type | canonical_json(payload) | created_at )"
          },
          "field_separator": {
            "type": "string",
            "description": "The character placed between the four fields before hashing: U+241F SYMBOL FOR UNIT SEPARATOR (`␟`), chosen because it cannot occur in JSON or ISO timestamps.",
            "const": "U+241F (␟)"
          }
        }
      },
      "AuditPublicKey": {
        "type": "object",
        "required": [
          "key_id",
          "format",
          "key"
        ],
        "properties": {
          "key_id": {
            "type": "string",
            "description": "Identifier of the signing key, so trails survive key rotation. Pin it."
          },
          "format": {
            "type": "string",
            "const": "spki-der-base64"
          },
          "key": {
            "type": "string",
            "description": "The Ed25519 public key: SPKI DER, base64 (44 characters)."
          }
        }
      },
      "AuditEvent": {
        "type": "object",
        "required": [
          "seq",
          "event_type",
          "payload",
          "prev_hash",
          "hash",
          "signature",
          "public_key_id",
          "created_at"
        ],
        "properties": {
          "seq": {
            "type": "integer",
            "minimum": 0,
            "description": "0-based, contiguous position in the chain."
          },
          "event_type": {
            "type": "string",
            "description": "What happened.",
            "enum": [
              "approval.requested",
              "message.queued",
              "message.sent",
              "message.failed",
              "approval.decided",
              "approval.expired",
              "webhook.delivered",
              "webhook.failed"
            ]
          },
          "payload": {
            "type": "object",
            "additionalProperties": true,
            "description": "Event-specific data, part of the hashed input (camelCase keys, as the engine recorded them). `approval.requested` carries the full request including `context` and `metadata`; `approval.decided` carries `outcome`, `decidedVia`, `decidedByPhone`, `rawInput`, `decidedAt`."
          },
          "prev_hash": {
            "type": "string",
            "pattern": "^[0-9a-f]{64}$",
            "description": "`hash` of the previous event; 64 zeros for `seq 0`."
          },
          "hash": {
            "type": "string",
            "pattern": "^[0-9a-f]{64}$",
            "description": "Lowercase hex SHA-256 computed per `algorithm.hash_formula` with `algorithm.field_separator`."
          },
          "signature": {
            "type": "string",
            "description": "Base64 Ed25519 signature (64 bytes) over the UTF-8 bytes of the hex `hash` string."
          },
          "public_key_id": {
            "type": "string",
            "description": "`key_id` of the key that signed this event."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "AuditTrail": {
        "type": "object",
        "required": [
          "approval_request_id",
          "algorithm",
          "public_key",
          "verification",
          "events"
        ],
        "properties": {
          "approval_request_id": {
            "type": "string",
            "format": "uuid"
          },
          "algorithm": {
            "$ref": "#/components/schemas/AuditAlgorithm"
          },
          "public_key": {
            "$ref": "#/components/schemas/AuditPublicKey"
          },
          "verification": {
            "$ref": "#/components/schemas/AuditVerification"
          },
          "events": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AuditEvent"
            },
            "description": "Ordered by `seq`."
          }
        }
      },
      "ApprovalDecidedEvent": {
        "type": "object",
        "description": "Body of the `approval.decided` webhook, in this field order (the signature covers the raw bytes). `params_hash`, `amount` and `currency` are `null` when not supplied at creation. New fields may be appended; existing ones never change meaning.",
        "required": [
          "id",
          "status",
          "outcome",
          "decided_via",
          "decided_at",
          "action",
          "params_hash",
          "approver_id",
          "amount",
          "currency"
        ],
        "additionalProperties": true,
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "The approval id."
          },
          "status": {
            "type": "string",
            "const": "decided"
          },
          "outcome": {
            "$ref": "#/components/schemas/DecisionOutcome"
          },
          "decided_via": {
            "$ref": "#/components/schemas/DecidedVia"
          },
          "decided_at": {
            "type": "string",
            "format": "date-time"
          },
          "action": {
            "type": "string",
            "description": "The human-readable action that was approved or rejected."
          },
          "params_hash": {
            "type": [
              "string",
              "null"
            ],
            "description": "The `params_hash` the approval was bound to. Re-check it against the params you are about to act on."
          },
          "approver_id": {
            "type": "string",
            "format": "uuid"
          },
          "amount": {
            "type": [
              "number",
              "null"
            ]
          },
          "currency": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "ErrorCode": {
        "type": "string",
        "description": "Stable machine-readable error codes.\n\n| code | HTTP | meaning |\n|---|---|---|\n| `unauthorized` | 401 | Missing, malformed, unknown or rotated API key |\n| `validation_error` | 400 | Body failed validation, unknown approver, or approver not opted in (see `details`) |\n| `not_found` | 404 | No such approval in this project, or no such route |\n| `conflict` | 409 | Reserved for concurrent-update conflicts; not raised by these routes today |\n| `rate_limited` | 429 | Over the per-minute limit on `POST /` (default 60) or on key rotation (5); honour `Retry-After` |\n| `quota_exceeded` | 402 | Monthly plan quota used up — `details` = `{ plan, limit, used, period }`; upgrade to continue |\n| `internal_error` | 500 | Unexpected server error |\n| `forbidden` | 403 | The signed-in user's role does not allow the action (Phase C) |\n| `insufficient_scope` | 403 | The API key lacks the scope the route needs — `approvals:write` for `POST /`, `approvals:read` for the reads (Phase C) |\n| `invite_expired` | 410 | The invite token is older than 7 days (Phase C) |\n| `invite_mismatch` | 403 | The signed-in email differs from the invited one (Phase C) |\n| `last_owner` | 409 | Removing or demoting the only owner (Phase C) |\n| `pending_invite` | 409 | `POST /account/bootstrap` for a user with no workspace but an open invite; `details` = `{ invite_token, workspace, role, expires_at }` (Phase C) |",
        "enum": [
          "unauthorized",
          "validation_error",
          "not_found",
          "conflict",
          "rate_limited",
          "quota_exceeded",
          "internal_error",
          "forbidden",
          "insufficient_scope",
          "invite_expired",
          "invite_mismatch",
          "last_owner",
          "pending_invite"
        ]
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "$ref": "#/components/schemas/ErrorCode"
              },
              "message": {
                "type": "string",
                "description": "Human-readable explanation. Not stable; do not parse."
              },
              "details": {
                "description": "Machine-readable detail: Zod `flatten()` output (`{ formErrors, fieldErrors }`) for body validation, `{ approver_id }` for approver problems, otherwise `null`. Omitted on 500.",
                "type": [
                  "object",
                  "null"
                ],
                "additionalProperties": true
              }
            }
          }
        },
        "examples": [
          {
            "error": {
              "code": "validation_error",
              "message": "Invalid request body.",
              "details": {
                "formErrors": [],
                "fieldErrors": {
                  "action": [
                    "String must contain at least 1 character(s)"
                  ]
                }
              }
            }
          }
        ]
      },
      "Role": {
        "type": "string",
        "enum": [
          "owner",
          "admin",
          "member"
        ],
        "description": "Workspace role. owner: everything incl. billing, ownership transfer and deleting the workspace; admin: approvers, API keys, webhooks, members (not owner); member: read-only.",
        "example": "admin",
        "x-approv-status": "phase-c"
      },
      "Member": {
        "type": "object",
        "required": [
          "id",
          "user_id",
          "email",
          "role",
          "status",
          "invited_at",
          "accepted_at",
          "last_seen_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Null until the invite is accepted."
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "Lowercased."
          },
          "role": {
            "$ref": "#/components/schemas/Role"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "invited"
            ]
          },
          "invited_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "accepted_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "last_seen_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        },
        "example": {
          "id": "7c1f6d2e-0f3a-4c9b-8d2e-1a2b3c4d5e6f",
          "user_id": "0f6c9a2e-1234-4bcd-9abc-000000000001",
          "email": "ada@example.com",
          "role": "admin",
          "status": "active",
          "invited_at": "2026-09-01T09:00:00.000Z",
          "accepted_at": "2026-09-01T09:12:40.000Z",
          "last_seen_at": "2026-10-02T08:15:00.000Z"
        },
        "x-approv-status": "phase-c"
      },
      "MembersResponse": {
        "type": "object",
        "required": [
          "me",
          "members"
        ],
        "properties": {
          "me": {
            "type": "object",
            "required": [
              "id",
              "role"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "role": {
                "$ref": "#/components/schemas/Role"
              }
            },
            "description": "The caller's own membership row."
          },
          "members": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Member"
            }
          }
        },
        "x-approv-status": "phase-c"
      },
      "InviteRequest": {
        "type": "object",
        "required": [
          "email",
          "role"
        ],
        "additionalProperties": false,
        "properties": {
          "email": {
            "type": "string",
            "format": "email"
          },
          "role": {
            "type": "string",
            "enum": [
              "admin",
              "member"
            ],
            "description": "`owner` is granted afterwards by an owner through PATCH."
          }
        },
        "example": {
          "email": "grace@example.com",
          "role": "member"
        },
        "x-approv-status": "phase-c"
      },
      "InviteResponse": {
        "type": "object",
        "required": [
          "member",
          "invite_url"
        ],
        "properties": {
          "member": {
            "$ref": "#/components/schemas/Member"
          },
          "invite_url": {
            "type": "string",
            "format": "uri",
            "description": "`${APP_URL}/invite/<token>`; the token works for 7 days and only for the invited address.",
            "example": "https://ai-approvel.com/invite/9f2c1d8e7b6a5f4e3d2c1b0a"
          }
        },
        "x-approv-status": "phase-c"
      },
      "UpdateMemberRequest": {
        "type": "object",
        "required": [
          "role"
        ],
        "additionalProperties": false,
        "properties": {
          "role": {
            "$ref": "#/components/schemas/Role"
          }
        },
        "example": {
          "role": "admin"
        },
        "x-approv-status": "phase-c"
      },
      "InviteInfo": {
        "type": "object",
        "required": [
          "workspace",
          "role",
          "email",
          "invited_by_email",
          "expires_at"
        ],
        "properties": {
          "workspace": {
            "type": "object",
            "required": [
              "id",
              "name"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "role": {
            "$ref": "#/components/schemas/Role"
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "The invited address; accepting requires signing in with it."
          },
          "invited_by_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "example": {
          "workspace": {
            "id": "3f0d2b1a-9c8e-4f7d-a6b5-c4d3e2f1a0b9",
            "name": "Acme Robotics"
          },
          "role": "member",
          "email": "grace@example.com",
          "invited_by_email": "ada@example.com",
          "expires_at": "2026-10-09T09:00:00.000Z"
        },
        "x-approv-status": "phase-c"
      },
      "AcceptInviteResponse": {
        "type": "object",
        "required": [
          "project",
          "role"
        ],
        "properties": {
          "project": {
            "type": "object",
            "required": [
              "id",
              "name"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "role": {
            "$ref": "#/components/schemas/Role"
          }
        },
        "example": {
          "project": {
            "id": "3f0d2b1a-9c8e-4f7d-a6b5-c4d3e2f1a0b9",
            "name": "Acme Robotics"
          },
          "role": "member"
        },
        "x-approv-status": "phase-c"
      },
      "ApiKeyScope": {
        "type": "string",
        "enum": [
          "approvals:write",
          "approvals:read"
        ],
        "description": "`approvals:write` → `POST /`; `approvals:read` → `GET /`, `GET /{id}`, `GET /{id}/audit`. A key without the needed scope gets `403 insufficient_scope`.",
        "x-approv-status": "phase-c"
      },
      "ApiKey": {
        "type": "object",
        "required": [
          "id",
          "name",
          "prefix",
          "scopes",
          "created_at",
          "created_by_email",
          "last_used_at",
          "expires_at",
          "revoked_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 60
          },
          "prefix": {
            "type": "string",
            "description": "Non-secret identifier `apk_live_<8 hex>`.",
            "pattern": "^apk_live_[0-9a-f]{8}$"
          },
          "scopes": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/ApiKeyScope"
            }
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_by_email": {
            "type": [
              "string",
              "null"
            ],
            "format": "email"
          },
          "last_used_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Refreshed at most every 5 minutes."
          },
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Null = never. Expired keys are rejected with 401."
          },
          "revoked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Revoked keys stay in the list for 30 days."
          }
        },
        "example": {
          "id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
          "name": "Production agent",
          "prefix": "apk_live_1a2b3c4d",
          "scopes": [
            "approvals:write"
          ],
          "created_at": "2026-10-02T09:30:00.000Z",
          "created_by_email": "ada@example.com",
          "last_used_at": "2026-10-02T10:02:11.000Z",
          "expires_at": "2026-12-31T09:30:00.000Z",
          "revoked_at": null
        },
        "x-approv-status": "phase-c"
      },
      "ApiKeyList": {
        "type": "object",
        "required": [
          "keys"
        ],
        "properties": {
          "keys": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ApiKey"
            }
          }
        },
        "x-approv-status": "phase-c"
      },
      "CreateApiKeyRequest": {
        "type": "object",
        "required": [
          "name",
          "scopes"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 60
          },
          "scopes": {
            "type": "array",
            "minItems": 1,
            "uniqueItems": true,
            "items": {
              "$ref": "#/components/schemas/ApiKeyScope"
            }
          },
          "expires_in_days": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 365,
            "description": "Null or omitted = never expires."
          }
        },
        "example": {
          "name": "Production agent",
          "scopes": [
            "approvals:write"
          ],
          "expires_in_days": 90
        },
        "x-approv-status": "phase-c"
      },
      "CreateApiKeyResponse": {
        "type": "object",
        "required": [
          "key",
          "api_key"
        ],
        "properties": {
          "key": {
            "$ref": "#/components/schemas/ApiKey"
          },
          "api_key": {
            "type": "string",
            "description": "The full key, `apk_live_<8 hex>_<secret>`. Shown exactly once.",
            "example": "apk_live_1a2b3c4d_u8Zq3mT2vLw9xPq4rS6tYbNc"
          }
        },
        "x-approv-status": "phase-c"
      },
      "RevokeApiKeyResponse": {
        "type": "object",
        "required": [
          "revoked",
          "id"
        ],
        "properties": {
          "revoked": {
            "const": true
          },
          "id": {
            "type": "string",
            "format": "uuid"
          }
        },
        "example": {
          "revoked": true,
          "id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a"
        },
        "x-approv-status": "phase-c"
      },
      "WebhookEventName": {
        "type": "string",
        "enum": [
          "approval.decided",
          "webhook.test"
        ],
        "description": "`approval.decided` is the event endpoints subscribe to; `webhook.test` is only sent by `POST /webhooks/endpoints/:id/test`.",
        "x-approv-status": "phase-c"
      },
      "DeliveryStatus": {
        "type": "string",
        "enum": [
          "pending",
          "succeeded",
          "failed"
        ],
        "x-approv-status": "phase-c"
      },
      "WebhookEndpoint": {
        "type": "object",
        "required": [
          "id",
          "url",
          "description",
          "events",
          "enabled",
          "secret_prefix",
          "created_at",
          "last_delivery_at",
          "last_status"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "https:// on a public host (same SSRF check as `callback_url`)."
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "events": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WebhookEventName"
            },
            "default": [
              "approval.decided"
            ]
          },
          "enabled": {
            "type": "boolean"
          },
          "secret_prefix": {
            "type": "string",
            "description": "Leading characters of the endpoint's `whsec_…` secret, to tell secrets apart."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "last_delivery_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "last_status": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/DeliveryStatus"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "example": {
          "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
          "url": "https://hooks.example.com/approv",
          "description": "Refund agent (prod)",
          "events": [
            "approval.decided"
          ],
          "enabled": true,
          "secret_prefix": "whsec_e2e00",
          "created_at": "2026-09-12T08:00:00.000Z",
          "last_delivery_at": "2026-10-02T10:05:00.000Z",
          "last_status": "succeeded"
        },
        "x-approv-status": "phase-c"
      },
      "WebhookEndpointList": {
        "type": "object",
        "required": [
          "endpoints"
        ],
        "properties": {
          "endpoints": {
            "type": "array",
            "maxItems": 10,
            "items": {
              "$ref": "#/components/schemas/WebhookEndpoint"
            }
          }
        },
        "x-approv-status": "phase-c"
      },
      "CreateWebhookEndpointRequest": {
        "type": "object",
        "required": [
          "url"
        ],
        "additionalProperties": false,
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "events": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/WebhookEventName"
            }
          }
        },
        "example": {
          "url": "https://hooks.example.com/approv",
          "description": "Refund agent (prod)",
          "events": [
            "approval.decided"
          ]
        },
        "x-approv-status": "phase-c"
      },
      "CreateWebhookEndpointResponse": {
        "type": "object",
        "required": [
          "endpoint",
          "secret"
        ],
        "properties": {
          "endpoint": {
            "$ref": "#/components/schemas/WebhookEndpoint"
          },
          "secret": {
            "type": "string",
            "description": "The endpoint's signing secret, `whsec_<43 base64url chars>`. Shown once; rotate to get a new one.",
            "example": "whsec_e2e0000000000000000000000000000000000000AAA"
          }
        },
        "x-approv-status": "phase-c"
      },
      "UpdateWebhookEndpointRequest": {
        "type": "object",
        "additionalProperties": false,
        "minProperties": 1,
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "enabled": {
            "type": "boolean"
          },
          "events": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/WebhookEventName"
            }
          }
        },
        "example": {
          "enabled": false
        },
        "x-approv-status": "phase-c"
      },
      "WebhookSecretResponse": {
        "type": "object",
        "required": [
          "secret"
        ],
        "properties": {
          "secret": {
            "type": "string"
          }
        },
        "example": {
          "secret": "whsec_rot0000000000000000000000000000000000000DDD"
        },
        "x-approv-status": "phase-c"
      },
      "WebhookDelivery": {
        "type": "object",
        "required": [
          "id",
          "endpoint_id",
          "approval_request_id",
          "event",
          "status",
          "attempts",
          "last_attempt_at",
          "response_status",
          "error",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "endpoint_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Null for the legacy `callback_url` delivery signed with the project secret."
          },
          "approval_request_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Null for `webhook.test`."
          },
          "event": {
            "$ref": "#/components/schemas/WebhookEventName"
          },
          "status": {
            "$ref": "#/components/schemas/DeliveryStatus"
          },
          "attempts": {
            "type": "integer",
            "minimum": 0,
            "maximum": 5
          },
          "last_attempt_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "response_status": {
            "type": [
              "integer",
              "null"
            ]
          },
          "error": {
            "type": [
              "string",
              "null"
            ]
          },
          "request_body": {
            "type": "string",
            "description": "The exact JSON bytes that were signed and sent. Only on `GET /webhooks/deliveries/:id`."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "example": {
          "id": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
          "endpoint_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
          "approval_request_id": "ff5bb3be-73ea-4cc6-92ae-fd1cc606bd2f",
          "event": "approval.decided",
          "status": "failed",
          "attempts": 5,
          "last_attempt_at": "2026-10-02T10:11:00.000Z",
          "response_status": 503,
          "error": "upstream returned 503 Service Unavailable",
          "created_at": "2026-10-02T10:05:00.000Z"
        },
        "x-approv-status": "phase-c"
      },
      "WebhookDeliveryList": {
        "type": "object",
        "required": [
          "deliveries",
          "next_cursor",
          "has_more"
        ],
        "properties": {
          "deliveries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WebhookDelivery"
            }
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Keyset cursor over `(created_at desc, id desc)`, like `GET /`."
          },
          "has_more": {
            "type": "boolean"
          }
        },
        "x-approv-status": "phase-c"
      }
    }
  }
}
