{
 "openapi": "3.1.0",
 "info": {
 "title": "SafeHanded API",
 "version": "1.0.0",
 "summary": "Create and track end-to-end encrypted credential handoff requests.",
 "description": "Authenticate every call with an organisation API key (`sh_live_\u2026`) as a bearer token. Keys are created under **API** in the SafeHanded app; each carries scopes, an optional client scope, an optional IP allowlist and an optional expiry.\n\n**Trying calls from the docs or Postman?** Use a dedicated key with only the scopes you need and a short expiry, and revoke it when you are done. A key typed into the docs page stays in that browser tab and is not saved.\n\n**There is no endpoint that returns a credential.** Envelopes are encrypted in the subject's browser to the recipients' identity keys and can only be opened by a recipient in their browser. The API creates requests, reads their state, ends them, feeds ticket statuses in and manages webhooks.\n\n**Idempotency.** Send an `Idempotency-Key` header (\u2264 200 characters) with any POST. The first response is stored for 24 hours and replayed for the same key and body (`Idempotency-Replayed: true`). The same key with a different body is refused with 422 `idempotency_key_reused`; a concurrent duplicate answers 409.\n\n**Rate limit.** 600 requests per minute per key. Exceeding it answers 429 with `Retry-After`.\n\n**Errors** are JSON `{ \"message\", \"code\" }`; validation errors add `errors` keyed by field.\n\n**Recipient commitment.** For API-created requests SafeHanded computes the recipient commitment that normally comes from the technician's browser and returns the complete link, fragment included. The fragment still lets the subject's browser detect later tampering with the stored recipient set, but not a compromised server at creation time. Deliver the full link to the subject, or ask SafeHanded to deliver it (`delivery.method`).",
 "contact": {
 "name": "SafeHanded",
 "url": "https://safehanded.com"
 }
 },
 "servers": [
 {
 "url": "https://safehanded.com/api/v1",
 "description": "SafeHanded cloud"
 }
 ],
 "security": [
 {
 "apiKey": []
 }
 ],
 "tags": [
 {
 "name": "Connection",
 "description": "Verify a key and identify its organisation."
 },
 {
 "name": "Requests",
 "description": "Credential handoff requests."
 },
 {
 "name": "Shares",
 "description": "Outbound secret shares."
 },
 {
 "name": "Events",
 "description": "The same events webhooks deliver, for polling."
 },
 {
 "name": "Tickets",
 "description": "Ticket status changes from any automation platform (close-to-purge)."
 },
 {
 "name": "Webhook subscriptions",
 "description": "Subscribe your URLs to events (REST hooks for connectors). The events SafeHanded then sends are described under Webhooks."
 }
 ],
 "paths": {
 "/generate": {
 "post": {
 "tags": [
 "Connection"
 ],
 "operationId": "generate",
 "summary": "Generate a strong credential",
 "description": "Returns a freshly generated password or passphrase. Nothing is stored, logged or associated with any request \u2014 it is a convenience for composing a credential to hand off. Any scope.",
 "requestBody": {
 "required": false,
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/GenerateBody"
 },
 "example": {
 "type": "passphrase"
 }
 }
 }
 },
 "responses": {
 "200": {
 "description": "The generated credential.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/GeneratedCredential"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "422": {
 "$ref": "#/components/responses/ValidationFailed"
 }
 }
 }
 },
 "/ping": {
 "get": {
 "tags": [
 "Connection"
 ],
 "operationId": "ping",
 "summary": "Check the API key",
 "description": "Returns the organisation and the key's scopes. Any scope; used by connectors as the connection test and label.",
 "responses": {
 "200": {
 "description": "The key is valid.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/Ping"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 }
 }
 }
 },
 "/requests": {
 "get": {
 "tags": [
 "Requests"
 ],
 "operationId": "listRequests",
 "summary": "List requests",
 "security": [
 {
 "apiKey": [
 "requests:read"
 ]
 }
 ],
 "description": "Newest first, cursor-paginated. Drafts are never listed. A client-scoped key only sees its client's requests.",
 "parameters": [
 {
 "name": "status",
 "in": "query",
 "schema": {
 "type": "string",
 "enum": [
 "pending",
 "opened",
 "submitted",
 "completed",
 "revoked",
 "expired"
 ]
 }
 },
 {
 "name": "client_id",
 "in": "query",
 "schema": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 },
 {
 "name": "ticket_ref",
 "in": "query",
 "schema": {
 "type": "string",
 "maxLength": 120
 }
 },
 {
 "$ref": "#/components/parameters/Limit"
 },
 {
 "$ref": "#/components/parameters/Cursor"
 }
 ],
 "responses": {
 "200": {
 "description": "A page of requests.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/RequestList"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "403": {
 "$ref": "#/components/responses/Forbidden"
 },
 "422": {
 "$ref": "#/components/responses/ValidationFailed"
 }
 }
 },
 "post": {
 "tags": [
 "Requests"
 ],
 "operationId": "createRequest",
 "summary": "Create a request",
 "security": [
 {
 "apiKey": [
 "requests:write"
 ]
 }
 ],
 "description": "Creates and commits a request in one call. Recipients are members of your organisation, addressed by email; each must have set up an identity key. The requester defaults to the first recipient. The response carries the complete link (fragment included) and, when the verification mode calls for one, the spoken challenge code \u2014 hand that to the subject out of band, never in the same message as the link.",
 "parameters": [
 {
 "$ref": "#/components/parameters/IdempotencyKey"
 }
 ],
 "requestBody": {
 "required": true,
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/CreateRequestBody"
 },
 "example": {
 "subject_name": "Jane Smith",
 "subject_email": "jane@example.com",
 "justification": "Finance mailbox password reset after a lockout",
 "recipients": [
 "tech@example.com"
 ],
 "ticket_ref": "INC-12345",
 "delivery": {
 "method": "email"
 }
 }
 }
 }
 },
 "responses": {
 "201": {
 "description": "Created and committed.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/RequestCreated"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "403": {
 "$ref": "#/components/responses/Forbidden"
 },
 "409": {
 "$ref": "#/components/responses/Conflict"
 },
 "422": {
 "$ref": "#/components/responses/ValidationFailed"
 }
 }
 }
 },
 "/requests/{id}": {
 "get": {
 "tags": [
 "Requests"
 ],
 "operationId": "getRequest",
 "summary": "Get a request",
 "security": [
 {
 "apiKey": [
 "requests:read"
 ]
 }
 ],
 "parameters": [
 {
 "$ref": "#/components/parameters/RequestId"
 }
 ],
 "responses": {
 "200": {
 "description": "The request.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/Request"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "403": {
 "$ref": "#/components/responses/Forbidden"
 },
 "404": {
 "$ref": "#/components/responses/NotFound"
 }
 }
 }
 },
 "/requests/{id}/revoke": {
 "post": {
 "tags": [
 "Requests"
 ],
 "operationId": "revokeRequest",
 "summary": "Revoke a live link",
 "security": [
 {
 "apiKey": [
 "requests:write"
 ]
 }
 ],
 "description": "Kills the link before the subject submits. Only `pending` and `opened` requests can be revoked; anything else answers 409 `illegal_transition`.",
 "parameters": [
 {
 "$ref": "#/components/parameters/RequestId"
 },
 {
 "$ref": "#/components/parameters/IdempotencyKey"
 }
 ],
 "responses": {
 "200": {
 "description": "Revoked.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/Request"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "403": {
 "$ref": "#/components/responses/Forbidden"
 },
 "404": {
 "$ref": "#/components/responses/NotFound"
 },
 "409": {
 "$ref": "#/components/responses/Conflict"
 }
 }
 }
 },
 "/requests/{id}/complete": {
 "post": {
 "tags": [
 "Requests"
 ],
 "operationId": "completeRequest",
 "summary": "Complete a request and destroy the credential",
 "security": [
 {
 "apiKey": [
 "requests:write"
 ]
 }
 ],
 "description": "Purges the stored envelope and attachments of a `submitted` request and marks it completed. Irreversible. Other states answer 409 `illegal_transition`.",
 "parameters": [
 {
 "$ref": "#/components/parameters/RequestId"
 },
 {
 "$ref": "#/components/parameters/IdempotencyKey"
 }
 ],
 "responses": {
 "200": {
 "description": "Completed.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/Request"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "403": {
 "$ref": "#/components/responses/Forbidden"
 },
 "404": {
 "$ref": "#/components/responses/NotFound"
 },
 "409": {
 "$ref": "#/components/responses/Conflict"
 }
 }
 }
 },
 "/requests/{id}/certificate": {
 "get": {
 "tags": [
 "Requests"
 ],
 "operationId": "getRequestCertificate",
 "summary": "Download a chain-of-custody certificate",
 "security": [
 {
 "apiKey": [
 "events:read"
 ]
 }
 ],
 "description": "A signed, offline-verifiable evidence document (`safehanded-custody/v1`) for one request: who requested it, whose key it was sealed to (fingerprints), and when it was submitted, revealed and purged. JSON by default (verify it with the published `tools/verify-custody-certificate`), or a rendered PDF with `?format=pdf`. Contains no secret material.",
 "parameters": [
 {
 "$ref": "#/components/parameters/RequestId"
 },
 {
 "name": "format",
 "in": "query",
 "description": "json (default, signed and verifiable) or pdf (a rendered document).",
 "schema": {
 "type": "string",
 "enum": [
 "json",
 "pdf"
 ],
 "default": "json"
 }
 }
 ],
 "responses": {
 "200": {
 "description": "The chain-of-custody certificate. A safehanded-custody/v1 JSON document by default, or application/pdf when format=pdf.",
 "content": {
 "application/json": {
 "schema": {
 "type": "object",
 "description": "safehanded-custody/v1 signed certificate."
 }
 },
 "application/pdf": {
 "schema": {
 "type": "string",
 "format": "binary"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "403": {
 "$ref": "#/components/responses/Forbidden"
 },
 "404": {
 "$ref": "#/components/responses/NotFound"
 }
 }
 }
 },
 "/shares/{id}/certificate": {
 "get": {
 "tags": [
 "Shares"
 ],
 "operationId": "getShareCertificate",
 "summary": "Download a chain-of-custody certificate",
 "security": [
 {
 "apiKey": [
 "events:read"
 ]
 }
 ],
 "description": "As `/requests/{id}/certificate`, for a share: the sealed-to recipient fingerprint and the custody events (created, revealed, expired, purged). JSON by default, PDF with `?format=pdf`. Contains no secret material.",
 "parameters": [
 {
 "name": "id",
 "in": "path",
 "required": true,
 "description": "The share id.",
 "schema": {
 "type": "string"
 }
 },
 {
 "name": "format",
 "in": "query",
 "description": "json (default, signed and verifiable) or pdf (a rendered document).",
 "schema": {
 "type": "string",
 "enum": [
 "json",
 "pdf"
 ],
 "default": "json"
 }
 }
 ],
 "responses": {
 "200": {
 "description": "The chain-of-custody certificate. A safehanded-custody/v1 JSON document by default, or application/pdf when format=pdf.",
 "content": {
 "application/json": {
 "schema": {
 "type": "object",
 "description": "safehanded-custody/v1 signed certificate."
 }
 },
 "application/pdf": {
 "schema": {
 "type": "string",
 "format": "binary"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "403": {
 "$ref": "#/components/responses/Forbidden"
 },
 "404": {
 "$ref": "#/components/responses/NotFound"
 }
 }
 }
 },
 "/events": {
 "get": {
 "tags": [
 "Events"
 ],
 "operationId": "listEvents",
 "summary": "Read the event feed",
 "security": [
 {
 "apiKey": [
 "events:read"
 ]
 }
 ],
 "description": "Events in the order they happened. Pass the previous response's `next_cursor` as `since` to continue; store it between polls. The objects are identical to webhook payloads.",
 "parameters": [
 {
 "name": "since",
 "in": "query",
 "description": "Cursor from a previous response (an integer sequence number); `0` starts from the oldest retained event.",
 "schema": {
 "type": "string",
 "pattern": "^[0-9]+$",
 "default": "0"
 }
 },
 {
 "name": "types",
 "in": "query",
 "description": "Comma-separated event types to include.",
 "schema": {
 "type": "string"
 },
 "example": "request.submitted,request.completed"
 },
 {
 "name": "limit",
 "in": "query",
 "schema": {
 "type": "integer",
 "minimum": 1,
 "maximum": 200,
 "default": 100
 }
 }
 ],
 "responses": {
 "200": {
 "description": "A page of events.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/EventList"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "403": {
 "$ref": "#/components/responses/Forbidden"
 },
 "422": {
 "$ref": "#/components/responses/ValidationFailed"
 }
 }
 }
 },
 "/tickets/{ref}/status": {
 "post": {
 "tags": [
 "Tickets"
 ],
 "operationId": "markTicketStatus",
 "summary": "Mark a ticket status",
 "security": [
 {
 "apiKey": [
 "tickets:write"
 ]
 }
 ],
 "description": "Tell SafeHanded a ticket changed status. Tickets linked through a ticketing integration follow the same path as the vendor's own webhook: where the adapter can read tickets the status is verified against the ticket system before anything is destroyed. Requests and shares that merely quote the reference in their ticket field are matched too; those statuses are unverified and only destroy credentials when the organisation has enabled **Trust unverified webhooks** in Policy. `closed` (and `resolved`, per policy) destroys the credential and completes the request; `open`/`pending` inside the grace window cancels a scheduled destruction.",
 "parameters": [
 {
 "name": "ref",
 "in": "path",
 "required": true,
 "description": "Your ticket reference exactly as entered on the request, or the external ticket id of a linked ticket.",
 "schema": {
 "type": "string",
 "minLength": 1,
 "maxLength": 120
 },
 "example": "INC-12345"
 },
 {
 "$ref": "#/components/parameters/IdempotencyKey"
 }
 ],
 "requestBody": {
 "required": true,
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/TicketStatusBody"
 },
 "example": {
 "status": "closed"
 }
 }
 }
 },
 "responses": {
 "202": {
 "description": "Accepted; lists what matched.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/TicketStatusAccepted"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "403": {
 "$ref": "#/components/responses/Forbidden"
 },
 "422": {
 "$ref": "#/components/responses/ValidationFailed"
 }
 }
 }
 },
 "/webhooks": {
 "get": {
 "tags": [
 "Webhook subscriptions"
 ],
 "operationId": "listWebhooks",
 "summary": "List webhook subscriptions",
 "security": [
 {
 "apiKey": [
 "webhooks:write"
 ]
 }
 ],
 "responses": {
 "200": {
 "description": "Subscriptions visible to this key.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/WebhookList"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "403": {
 "$ref": "#/components/responses/Forbidden"
 }
 }
 },
 "post": {
 "tags": [
 "Webhook subscriptions"
 ],
 "operationId": "createWebhook",
 "summary": "Subscribe a URL to events",
 "security": [
 {
 "apiKey": [
 "webhooks:write"
 ]
 }
 ],
 "description": "Creates a webhook owned by this key (client-scoped when the key is). The signing `secret` is returned once. Deliveries carry the Standard Webhooks headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`), so you can verify them with any Standard Webhooks library; the legacy `X-SafeHanded-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, \"<t>.<body>\")>` header is sent alongside during the deprecation window. Retried for 24 hours.",
 "parameters": [
 {
 "$ref": "#/components/parameters/IdempotencyKey"
 }
 ],
 "requestBody": {
 "required": true,
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/CreateWebhookBody"
 },
 "example": {
 "url": "https://hooks.example.com/safehanded",
 "events": [
 "request.submitted",
 "request.completed"
 ],
 "description": "Service desk notifications"
 }
 }
 }
 },
 "responses": {
 "201": {
 "description": "Created; includes the secret.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/WebhookCreated"
 }
 }
 }
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "403": {
 "$ref": "#/components/responses/Forbidden"
 },
 "422": {
 "$ref": "#/components/responses/ValidationFailed"
 }
 }
 }
 },
 "/webhooks/{id}": {
 "delete": {
 "tags": [
 "Webhook subscriptions"
 ],
 "operationId": "deleteWebhook",
 "summary": "Remove a webhook subscription",
 "security": [
 {
 "apiKey": [
 "webhooks:write"
 ]
 }
 ],
 "parameters": [
 {
 "name": "id",
 "in": "path",
 "required": true,
 "schema": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 }
 ],
 "responses": {
 "204": {
 "description": "Removed."
 },
 "401": {
 "$ref": "#/components/responses/Unauthenticated"
 },
 "403": {
 "$ref": "#/components/responses/Forbidden"
 },
 "404": {
 "$ref": "#/components/responses/NotFound"
 }
 }
 }
 }
 },
 "webhooks": {
 "event": {
 "post": {
 "operationId": "eventDelivery",
 "summary": "Event delivery",
 "description": "SafeHanded POSTs one event per delivery to each subscribed URL. Verify with a Standard Webhooks library over the `webhook-id`/`webhook-timestamp`/`webhook-signature` headers (or the legacy `X-SafeHanded-Signature`) against the raw body, answer 2xx within 10 seconds, and de-duplicate on the event `id` in the body (deliveries are at-least-once; automatic retries reuse the `webhook-id`, but a redelivery from the app is a new delivery with a new `webhook-id` and the same event `id`). Retries after failure: 1 m, 5 m, 30 m, 2 h, 6 h, 12 h, 24 h.",
 "parameters": [
 {
 "name": "webhook-id",
 "in": "header",
 "required": true,
 "schema": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "description": "Standard Webhooks message id, one per delivery (kept across automatic retries, new on a manual redelivery). De-duplicate on the event `id` in the body."
 },
 {
 "name": "webhook-timestamp",
 "in": "header",
 "required": true,
 "schema": {
 "type": "string"
 },
 "example": "1757160000"
 },
 {
 "name": "webhook-signature",
 "in": "header",
 "required": true,
 "schema": {
 "type": "string"
 },
 "example": "v1,K5oM\u2026"
 },
 {
 "name": "X-SafeHanded-Signature",
 "in": "header",
 "required": false,
 "schema": {
 "type": "string"
 },
 "example": "t=1757160000,v1=3f9a\u2026",
 "deprecated": true
 },
 {
 "name": "X-SafeHanded-Event",
 "in": "header",
 "required": true,
 "schema": {
 "type": "string",
 "enum": [
 "request.created",
 "request.opened",
 "request.submitted",
 "request.revealed",
 "request.completed",
 "credential.rotation_recommended",
 "request.expired",
 "request.revoked",
 "request.reported",
 "member.disabled",
 "org_recovery.used",
 "ping"
 ]
 }
 },
 {
 "name": "X-SafeHanded-Delivery",
 "in": "header",
 "required": true,
 "schema": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 }
 ],
 "requestBody": {
 "required": true,
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/Event"
 }
 }
 }
 },
 "responses": {
 "2XX": {
 "description": "Delivered. Any other status or a timeout schedules a retry."
 }
 }
 }
 }
 },
 "components": {
 "securitySchemes": {
 "apiKey": {
 "type": "http",
 "scheme": "bearer",
 "bearerFormat": "sh_live_\u2026",
 "description": "An organisation API key. Scopes: `requests:read` (Read requests), `requests:write` (Create, revoke and complete requests), `tickets:write` (Mark ticket statuses (close-to-purge)), `events:read` (Read the event feed), `webhooks:write` (Manage webhook subscriptions)."
 }
 },
 "parameters": {
 "IdempotencyKey": {
 "name": "Idempotency-Key",
 "in": "header",
 "required": false,
 "description": "Any unique string (\u2264 200 characters) to make this POST safe to retry.",
 "schema": {
 "type": "string",
 "maxLength": 200
 },
 "example": "b7e3c1f0-4a2d-4f7e-9c1a-2d5e8f0a1b3c"
 },
 "Limit": {
 "name": "limit",
 "in": "query",
 "schema": {
 "type": "integer",
 "minimum": 1,
 "maximum": 100,
 "default": 50
 }
 },
 "Cursor": {
 "name": "cursor",
 "in": "query",
 "description": "`next_cursor` from the previous page.",
 "schema": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 },
 "RequestId": {
 "name": "id",
 "in": "path",
 "required": true,
 "schema": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 }
 },
 "responses": {
 "Unauthenticated": {
 "description": "Missing, unknown, revoked or expired API key.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/Error"
 }
 }
 }
 },
 "Forbidden": {
 "description": "The key lacks the scope, is scoped to another client, or is used from an address outside its allowlist.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/Error"
 }
 }
 }
 },
 "NotFound": {
 "description": "No such record within this key's scope.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/Error"
 }
 }
 }
 },
 "Conflict": {
 "description": "The state does not allow this transition, or an Idempotency-Key is still being processed.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/Error"
 }
 }
 }
 },
 "ValidationFailed": {
 "description": "The body or query is invalid (`errors` lists the fields), or an Idempotency-Key was reused with a different body.",
 "content": {
 "application/json": {
 "schema": {
 "$ref": "#/components/schemas/ValidationError"
 }
 }
 }
 }
 },
 "schemas": {
 "Error": {
 "type": "object",
 "required": [
 "message",
 "code"
 ],
 "properties": {
 "message": {
 "type": "string"
 },
 "code": {
 "type": "string",
 "enum": [
 "unauthenticated",
 "forbidden",
 "not_found",
 "validation_failed",
 "conflict",
 "illegal_transition",
 "idempotency_key_reused",
 "rate_limited",
 "bad_request",
 "method_not_allowed",
 "error"
 ]
 }
 }
 },
 "ValidationError": {
 "allOf": [
 {
 "$ref": "#/components/schemas/Error"
 },
 {
 "type": "object",
 "properties": {
 "errors": {
 "type": "object",
 "additionalProperties": {
 "type": "array",
 "items": {
 "type": "string"
 }
 }
 }
 }
 }
 ]
 },
 "Ping": {
 "type": "object",
 "required": [
 "ok",
 "organisation",
 "key",
 "label"
 ],
 "properties": {
 "ok": {
 "type": "boolean",
 "const": true
 },
 "organisation": {
 "type": "object",
 "properties": {
 "id": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "name": {
 "type": "string"
 }
 }
 },
 "key": {
 "type": "object",
 "properties": {
 "name": {
 "type": "string"
 },
 "prefix": {
 "type": "string",
 "example": "sh_live_Ab3dEf9h\u2026"
 },
 "scopes": {
 "type": "array",
 "items": {
 "type": "string",
 "enum": [
 "requests:read",
 "requests:write",
 "tickets:write",
 "events:read",
 "webhooks:write"
 ]
 }
 },
 "client": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "$ref": "#/components/schemas/ClientRef"
 }
 ]
 },
 "expires_at": {
 "type": [
 "string",
 "null"
 ],
 "format": "date-time"
 }
 }
 },
 "label": {
 "type": "string",
 "description": "A human label for the connection, e.g. \"Acme IT (helpdesk automation)\"."
 }
 }
 },
 "ClientRef": {
 "type": "object",
 "required": [
 "id",
 "name"
 ],
 "properties": {
 "id": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "name": {
 "type": "string"
 }
 }
 },
 "GenerateBody": {
 "type": "object",
 "properties": {
 "type": {
 "type": "string",
 "enum": [
 "password",
 "passphrase"
 ],
 "default": "password"
 },
 "length": {
 "type": "integer",
 "minimum": 8,
 "maximum": 128,
 "default": 20,
 "description": "Password length (ignored for a passphrase)."
 },
 "lower": {
 "type": "boolean",
 "default": true
 },
 "upper": {
 "type": "boolean",
 "default": true
 },
 "digits": {
 "type": "boolean",
 "default": true
 },
 "symbols": {
 "type": "boolean",
 "default": false
 }
 }
 },
 "GeneratedCredential": {
 "type": "object",
 "required": [
 "object",
 "type",
 "value",
 "length"
 ],
 "properties": {
 "object": {
 "type": "string",
 "const": "generated_credential"
 },
 "type": {
 "type": "string",
 "enum": [
 "password",
 "passphrase"
 ]
 },
 "value": {
 "type": [
 "string",
 "null"
 ],
 "description": "The generated credential. Never stored server-side, so `null` on an idempotent replay (`Idempotency-Replayed: true`)."
 },
 "length": {
 "type": "integer"
 }
 }
 },
 "Request": {
 "type": "object",
 "required": [
 "id",
 "object",
 "status",
 "client",
 "subject",
 "created_at"
 ],
 "properties": {
 "id": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "object": {
 "type": "string",
 "const": "request"
 },
 "status": {
 "type": "string",
 "enum": [
 "pending",
 "opened",
 "submitted",
 "completed",
 "revoked",
 "expired"
 ],
 "description": "`pending` link issued \u00b7 `opened` subject opened it \u00b7 `submitted` credential stored, awaiting reveal \u00b7 `completed` destroyed \u00b7 `revoked` \u00b7 `expired`."
 },
 "client": {
 "$ref": "#/components/schemas/ClientRef"
 },
 "subject": {
 "type": "object",
 "properties": {
 "name": {
 "type": "string"
 },
 "email": {
 "type": [
 "string",
 "null"
 ],
 "format": "email"
 },
 "phone": {
 "type": [
 "string",
 "null"
 ]
 }
 }
 },
 "ticket": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "type": "object",
 "properties": {
 "ref": {
 "type": [
 "string",
 "null"
 ]
 },
 "url": {
 "type": [
 "string",
 "null"
 ],
 "format": "uri"
 },
 "external_id": {
 "type": [
 "string",
 "null"
 ]
 },
 "integration_id": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 ]
 },
 "status": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "type": "string",
 "enum": [
 "open",
 "pending",
 "resolved",
 "closed",
 "unknown"
 ]
 }
 ]
 }
 }
 }
 ]
 },
 "justification": {
 "type": "string"
 },
 "template": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "type": "object",
 "properties": {
 "id": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "name": {
 "type": "string"
 }
 }
 }
 ]
 },
 "requester": {
 "type": "object",
 "properties": {
 "membership_id": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "name": {
 "type": [
 "string",
 "null"
 ]
 },
 "email": {
 "type": [
 "string",
 "null"
 ],
 "format": "email"
 }
 }
 },
 "recipients": {
 "type": "array",
 "items": {
 "type": "object",
 "properties": {
 "label": {
 "type": "string"
 },
 "membership_id": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 ]
 }
 }
 },
 "description": "Who can open the credential. Labels only; keys never leave the browser."
 },
 "ttl_minutes": {
 "type": "integer"
 },
 "verification_mode": {
 "type": "string",
 "enum": [
 "none",
 "challenge",
 "email_otp",
 "either",
 "both"
 ]
 },
 "allow_attachments": {
 "type": "boolean"
 },
 "attachments_count": {
 "type": "integer"
 },
 "needs_relink": {
 "type": "boolean"
 },
 "unrecoverable": {
 "type": "boolean"
 },
 "reveal_count": {
 "type": "integer"
 },
 "purge_reason": {
 "type": [
 "string",
 "null"
 ]
 },
 "revoked_reason": {
 "type": [
 "string",
 "null"
 ]
 },
 "created_at": {
 "type": "string",
 "format": "date-time"
 },
 "link_expires_at": {
 "type": [
 "string",
 "null"
 ],
 "format": "date-time"
 },
 "opened_at": {
 "type": [
 "string",
 "null"
 ],
 "format": "date-time"
 },
 "submitted_at": {
 "type": [
 "string",
 "null"
 ],
 "format": "date-time"
 },
 "record_expires_at": {
 "type": [
 "string",
 "null"
 ],
 "format": "date-time"
 },
 "last_revealed_at": {
 "type": [
 "string",
 "null"
 ],
 "format": "date-time"
 },
 "purge_scheduled_at": {
 "type": [
 "string",
 "null"
 ],
 "format": "date-time"
 },
 "completed_at": {
 "type": [
 "string",
 "null"
 ],
 "format": "date-time"
 },
 "revoked_at": {
 "type": [
 "string",
 "null"
 ],
 "format": "date-time"
 },
 "links": {
 "type": "object",
 "properties": {
 "self": {
 "type": "string",
 "format": "uri"
 },
 "app": {
 "type": "string",
 "format": "uri"
 }
 }
 }
 }
 },
 "RequestList": {
 "type": "object",
 "required": [
 "object",
 "data",
 "has_more"
 ],
 "properties": {
 "object": {
 "type": "string",
 "const": "list"
 },
 "data": {
 "type": "array",
 "items": {
 "$ref": "#/components/schemas/Request"
 }
 },
 "has_more": {
 "type": "boolean"
 },
 "next_cursor": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 ]
 }
 }
 },
 "CreateRequestBody": {
 "type": "object",
 "required": [
 "subject_name",
 "justification",
 "recipients"
 ],
 "properties": {
 "subject_name": {
 "type": "string",
 "minLength": 2,
 "maxLength": 120,
 "description": "The person handing over the credential."
 },
 "subject_email": {
 "type": "string",
 "format": "email",
 "description": "Needed for emailed one-time codes and for `delivery.method = email`."
 },
 "subject_phone": {
 "type": "string",
 "maxLength": 40,
 "description": "A mobile number, needed for `delivery.method = sms`. E.164 (`+447700900123`) is preferred; a national number (`07700 900123`) is read in the platform default country (UK). Stored and returned as E.164."
 },
 "justification": {
 "type": "string",
 "minLength": 10,
 "maxLength": 1000
 },
 "recipients": {
 "type": "array",
 "minItems": 1,
 "maxItems": 64,
 "items": {
 "type": "string",
 "format": "email"
 },
 "description": "Emails of active members who will be able to open the credential. Each needs an identity key."
 },
 "requester": {
 "type": "string",
 "format": "email",
 "description": "Member recorded as the requester; defaults to the first recipient. Always a recipient."
 },
 "teams": {
 "type": "array",
 "items": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "description": "Team ids whose members are added as recipients."
 },
 "client_id": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "preset": {
 "type": "string",
 "maxLength": 40,
 "description": "A request preset slug (Team plan and above). Its saved defaults \u2014 justification, TTL, verification, template, recipients, delivery \u2014 are applied first; the fields below override them."
 },
 "template_id": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "ticket_ref": {
 "type": "string",
 "maxLength": 120,
 "description": "Your ticket reference. Closing it (via `POST /tickets/{ref}/status` or a ticketing integration) can destroy the credential per policy."
 },
 "ticket_url": {
 "type": "string",
 "format": "uri"
 },
 "ticket_integration_id": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "ticket_external_id": {
 "type": "string",
 "maxLength": 64
 },
 "ttl_minutes": {
 "type": "integer",
 "minimum": 5,
 "maximum": 43200,
 "description": "How long a submitted credential is kept; must be one of the policy's options. Defaults to the policy default."
 },
 "verification_mode": {
 "type": "string",
 "enum": [
 "none",
 "challenge",
 "email_otp",
 "either",
 "both"
 ],
 "description": "At least the policy minimum. Defaults to the policy default."
 },
 "allow_attachments": {
 "type": "boolean",
 "default": false
 },
 "delivery": {
 "type": "object",
 "properties": {
 "method": {
 "type": "string",
 "enum": [
 "none",
 "email",
 "sms",
 "ticket_reply"
 ],
 "default": "none",
 "description": "`none`: you deliver the returned link. `email`/`sms`: SafeHanded sends it (branded) to the subject. `ticket_reply`: posts it as a customer-visible reply on the linked ticket (needs a reply-capable ticketing integration)."
 }
 }
 }
 }
 },
 "RequestCreated": {
 "type": "object",
 "required": [
 "request",
 "link",
 "commit",
 "commitment"
 ],
 "properties": {
 "request": {
 "$ref": "#/components/schemas/Request"
 },
 "link": {
 "type": [
 "string",
 "null"
 ],
 "format": "uri",
 "description": "The complete link for the subject, fragment included. Returned once: `null` on an idempotent replay (`Idempotency-Replayed: true`), because the link token is never cached; retry without the key for a new request."
 },
 "link_without_fragment": {
 "type": [
 "string",
 "null"
 ],
 "format": "uri",
 "description": "The link without its fragment; `null` on an idempotent replay (`Idempotency-Replayed: true`), like `link`."
 },
 "commit": {
 "type": "string",
 "description": "The recipient commitment (22 base64url characters) that forms the fragment."
 },
 "challenge_code": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "type": "string",
 "pattern": "^[0-9]{6}$"
 }
 ],
 "description": "Spoken challenge code when the verification mode needs one (`null` on an idempotent replay). Tell the subject by phone or in person, never alongside the link."
 },
 "delivery": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "type": "object",
 "properties": {
 "id": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "method": {
 "type": "string"
 }
 }
 }
 ]
 },
 "excluded_recipients": {
 "type": "array",
 "items": {
 "type": "string"
 },
 "description": "Names of members left out because their key could not be verified (for example a replaced key an admin has not approved). Empty when everyone was included."
 },
 "commitment": {
 "type": "object",
 "properties": {
 "computed_by": {
 "type": "string",
 "const": "server"
 },
 "note": {
 "type": "string"
 }
 }
 }
 }
 },
 "Event": {
 "type": "object",
 "required": [
 "id",
 "object",
 "type",
 "occurred_at",
 "organisation_id",
 "data"
 ],
 "properties": {
 "id": {
 "type": "string",
 "example": "evt_1042",
 "description": "Stable per organisation; de-duplicate on it."
 },
 "object": {
 "type": "string",
 "const": "event"
 },
 "type": {
 "type": "string",
 "enum": [
 "request.created",
 "request.opened",
 "request.submitted",
 "request.revealed",
 "request.completed",
 "credential.rotation_recommended",
 "request.expired",
 "request.revoked",
 "request.reported",
 "member.disabled",
 "org_recovery.used",
 "ping"
 ]
 },
 "occurred_at": {
 "type": "string",
 "format": "date-time"
 },
 "organisation_id": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "client_id": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 ]
 },
 "seq": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "type": "integer"
 }
 ],
 "description": "The `since` cursor value that includes this event."
 },
 "data": {
 "type": "object",
 "properties": {
 "request": {
 "$ref": "#/components/schemas/Request"
 },
 "member": {
 "type": "object",
 "properties": {
 "membership_id": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "name": {
 "type": [
 "string",
 "null"
 ]
 },
 "email": {
 "type": [
 "string",
 "null"
 ]
 }
 }
 },
 "justification": {
 "type": "string"
 },
 "details": {
 "type": "object",
 "description": "Audit metadata for the event (e.g. `purge_reason`, `revoked_reason`, `reveal_count`, `channel`); always an object, `{}` when there is none. Never secrets."
 },
 "actor": {
 "type": "object",
 "properties": {
 "type": {
 "type": "string",
 "enum": [
 "member",
 "subject",
 "system",
 "api"
 ]
 },
 "label": {
 "type": [
 "string",
 "null"
 ]
 }
 }
 }
 }
 }
 }
 },
 "EventList": {
 "type": "object",
 "required": [
 "object",
 "data",
 "has_more",
 "next_cursor"
 ],
 "properties": {
 "object": {
 "type": "string",
 "const": "list"
 },
 "data": {
 "type": "array",
 "items": {
 "$ref": "#/components/schemas/Event"
 }
 },
 "has_more": {
 "type": "boolean"
 },
 "next_cursor": {
 "type": "string",
 "pattern": "^[0-9]+$"
 }
 }
 },
 "TicketStatusBody": {
 "type": "object",
 "required": [
 "status"
 ],
 "properties": {
 "status": {
 "type": "string",
 "enum": [
 "open",
 "pending",
 "resolved",
 "closed"
 ]
 },
 "integration_id": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 ],
 "description": "Only match tickets linked through this integration."
 }
 }
 },
 "TicketStatusAccepted": {
 "type": "object",
 "required": [
 "accepted",
 "matched"
 ],
 "properties": {
 "accepted": {
 "type": "boolean",
 "const": true
 },
 "ticket_ref": {
 "type": "string"
 },
 "status": {
 "type": "string"
 },
 "matched": {
 "type": "object",
 "properties": {
 "ticket_links": {
 "type": "integer"
 },
 "requests": {
 "type": "array",
 "items": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 },
 "shares": {
 "type": "array",
 "items": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 }
 }
 },
 "verified": {
 "type": "boolean",
 "const": false
 },
 "note": {
 "type": "string"
 }
 }
 },
 "Webhook": {
 "type": "object",
 "required": [
 "id",
 "object",
 "url",
 "events",
 "is_active"
 ],
 "properties": {
 "id": {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 },
 "object": {
 "type": "string",
 "const": "webhook"
 },
 "url": {
 "type": "string",
 "format": "uri"
 },
 "description": {
 "type": [
 "string",
 "null"
 ]
 },
 "events": {
 "type": "array",
 "items": {
 "type": "string",
 "enum": [
 "request.created",
 "request.opened",
 "request.submitted",
 "request.revealed",
 "request.completed",
 "credential.rotation_recommended",
 "request.expired",
 "request.revoked",
 "request.reported",
 "member.disabled",
 "org_recovery.used"
 ]
 }
 },
 "is_active": {
 "type": "boolean"
 },
 "client_id": {
 "oneOf": [
 {
 "type": "null"
 },
 {
 "type": "string",
 "pattern": "^[0-9a-z]{26}$",
 "description": "ULID",
 "example": "01j9z3k5m7p9r2t4v6x8y0a2b4"
 }
 ]
 },
 "created_via": {
 "type": "string",
 "enum": [
 "ui",
 "api"
 ]
 },
 "failing_since": {
 "type": [
 "string",
 "null"
 ],
 "format": "date-time"
 },
 "last_delivered_at": {
 "type": [
 "string",
 "null"
 ],
 "format": "date-time"
 },
 "created_at": {
 "type": "string",
 "format": "date-time"
 }
 }
 },
 "WebhookList": {
 "type": "object",
 "required": [
 "object",
 "data"
 ],
 "properties": {
 "object": {
 "type": "string",
 "const": "list"
 },
 "data": {
 "type": "array",
 "items": {
 "$ref": "#/components/schemas/Webhook"
 }
 }
 }
 },
 "CreateWebhookBody": {
 "type": "object",
 "required": [
 "url",
 "events"
 ],
 "properties": {
 "url": {
 "type": "string",
 "format": "uri",
 "description": "https only, public host."
 },
 "events": {
 "type": "array",
 "minItems": 1,
 "items": {
 "type": "string",
 "enum": [
 "request.created",
 "request.opened",
 "request.submitted",
 "request.revealed",
 "request.completed",
 "credential.rotation_recommended",
 "request.expired",
 "request.revoked",
 "request.reported",
 "member.disabled",
 "org_recovery.used"
 ]
 }
 },
 "description": {
 "type": "string",
 "maxLength": 200
 }
 }
 },
 "WebhookCreated": {
 "allOf": [
 {
 "$ref": "#/components/schemas/Webhook"
 },
 {
 "type": "object",
 "required": [
 "secret"
 ],
 "properties": {
 "secret": {
 "type": [
 "string",
 "null"
 ],
 "description": "Signing secret; shown once, so `null` on an idempotent replay (`Idempotency-Replayed: true`)."
 }
 }
 }
 ]
 }
 }
 }
}
