{
  "openapi": "3.1.0",
  "info": {
    "title": "BeaconBox API",
    "version": "0.1.0",
    "description": "The API-key authenticated surface: everything a merchant integration can call. The session-authenticated admin surface is not part of this document."
  },
  "servers": [
    {
      "url": "https://api.beaconbox.eu",
      "description": "Production"
    }
  ],
  "paths": {
    "/api/v1/messages": {
      "post": {
        "tags": [
          "messages"
        ],
        "summary": "Push Message",
        "operationId": "push_message_api_v1_messages_post",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours.",
              "title": "Idempotency-Key"
            },
            "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours."
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MessagePush"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessagePushResult"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "messages"
        ],
        "summary": "List Messages",
        "operationId": "list_messages_api_v1_messages_get",
        "parameters": [
          {
            "name": "recipient_email",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Recipient Email"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "title": "Limit"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Cursor"
            }
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageListResponse"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/messages/batch": {
      "post": {
        "tags": [
          "messages"
        ],
        "summary": "Push Batch",
        "description": "Push up to 100 messages in one call, with per-item outcomes.\n\n200 whenever the batch is processed: check each item's ``ok``, not the status code. One\nbad recipient must not discard the pushes alongside it, and a 4xx for the whole call would\ninvite a retry of every item.\n\nSafe to retry: items already pushed are replayed rather than sent again.\n\n**409** `idempotency.request_in_progress` while another request with the same key is still\nrunning this batch, as for a single push. It is never an item's result: wait a few seconds and\nretry the whole batch with the same key, and every item's real outcome comes back.\n\nThe `Idempotency-Key` on a batch is limited to 252 characters rather than 255, because each\nitem's own key is derived from it by appending `#<index>`. A longer one is refused with 422\nbefore anything runs.",
        "operationId": "push_batch_api_v1_messages_batch_post",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours.",
              "title": "Idempotency-Key"
            },
            "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours."
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MessageBatchPush"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageBatchResult"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/messages/{public_id}/sms": {
      "post": {
        "tags": [
          "messages"
        ],
        "summary": "Resend Sms",
        "description": "Send the SMS for a message whose text never went out.\n\nThe recovery path for a push that reported ``sms.skipped_reason``, most often an empty balance\nthat has since been topped up. Separate from pushing because re-pushing to get a text stores a\nsecond inbox message for ``kind=one_off``.\n\nRefused with 409 if the SMS is already queued or delivered: the only thing a second send would\nadd is a second charge and a second interruption. Refused with 409 `sms.may_have_sent` when an\nearlier text for this message ended without the carrier's answer, so it may have arrived.",
        "operationId": "resend_sms_api_v1_messages__public_id__sms_post",
        "parameters": [
          {
            "name": "public_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Public Id"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours.",
              "title": "Idempotency-Key"
            },
            "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours."
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SmsOutcome"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/messages/{public_id}/whatsapp": {
      "post": {
        "tags": [
          "messages"
        ],
        "summary": "Resend Whatsapp",
        "description": "Send the WhatsApp message for one whose template never went out.\n\n`POST /messages/{id}/sms`'s sibling, on the same contract: a policy refusal is a **200** with\n`skipped_reason`, never an error, because the update is already in the recipient's inbox and\nthe email nudge went out regardless. Most often the recovery for `insufficient_credit` after a\ntop-up, or for `template_not_sendable` once Meta has approved the template you were waiting on.\n\nThree 409s, all decided before anything is written so your `Idempotency-Key` stays unspent:\n\n* `whatsapp.already_sent`: this message's WhatsApp send is already queued, delivered or read.\n* `whatsapp.escalated_to_sms`: you asked for `sms_if_whatsapp_fails` on the push, WhatsApp\n  proved undeliverable, and the text has already gone. The customer has been told; a WhatsApp\n  message now would be the second interruption about one update.\n* `whatsapp.may_have_sent`: an earlier WhatsApp message for this message ended without Meta's\n  answer, so it may have arrived.\n\nIt does **not** re-arm `sms_if_whatsapp_fails`: a resend names one channel, once.",
        "operationId": "resend_whatsapp_api_v1_messages__public_id__whatsapp_post",
        "parameters": [
          {
            "name": "public_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Public Id"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours.",
              "title": "Idempotency-Key"
            },
            "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours."
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WhatsAppOutcome"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/messages/{public_id}/retract": {
      "post": {
        "tags": [
          "messages"
        ],
        "summary": "Retract Message",
        "description": "Take back a message you have already pushed.\n\nThe message is marked **withdrawn** for the recipient: dimmed in their inbox and labelled as\ntaken back, rather than deleted, because they may already have read it. Any nudge still queued\nfor it (email, SMS or WhatsApp, including one deferred with `send_at`) is called off, and a\ncancelled SMS costs nothing: the credit is charged at send time, so a send that never happens\nis never billed.\n\nIdempotent. Retracting twice is not an error: the second call reports `retracted: false` and\nthe message's current status.\n\nUse this rather than pushing an empty update to cancel one: `obsoletes` on a later push says\n*a newer message replaces those*, which is a different thing to tell a customer.",
        "operationId": "retract_message_api_v1_messages__public_id__retract_post",
        "parameters": [
          {
            "name": "public_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Public Id"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours.",
              "title": "Idempotency-Key"
            },
            "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours."
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RetractResult"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/messages/{public_id}": {
      "get": {
        "tags": [
          "messages"
        ],
        "summary": "Get Message",
        "operationId": "get_message_api_v1_messages__public_id__get",
        "parameters": [
          {
            "name": "public_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Public Id"
            }
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageStatusView"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/keys": {
      "get": {
        "tags": [
          "keys"
        ],
        "summary": "List Keys",
        "operationId": "list_keys_api_v1_keys_get",
        "parameters": [
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KeyListResponse"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "keys"
        ],
        "summary": "Create Key",
        "description": "Create an API key for your business. The full key is in this response only, so store it now.\n\nThe new key is a child of the key that created it: revoking a key also revokes every key it\ncreated without `detach`, and every such key those created in turn. A key created from the\ndashboard has no parent and is revoked only by its own id. A business holds at most 25 active\nkeys.\n\nTo rotate a key through the API, send `detach: true`: the new key has no parent and keeps\nworking after you revoke the key that created it. Create the detached key, move your systems to\nit, then revoke the old one. Anyone holding a key can do the same, so after a leak the cascade\ndoes not reach a detached key the leaked key created: every key records the key that created\nit as `minted_by` in `GET /keys`, detached or not, and those are the ones to revoke too.",
        "operationId": "create_key_api_v1_keys_post",
        "parameters": [
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateApiKeyRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NewKeyResponse"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/keys/{key_id}": {
      "delete": {
        "tags": [
          "keys"
        ],
        "summary": "Revoke Key",
        "description": "Revoke a key so it stops authenticating at once.\n\nRevoking a key also revokes every key it created through this API without `detach`, and every\nsuch key those created in turn. A key created with `detach: true` keeps working, and a leaked\nkey can create one: after revoking a leaked key, list the keys and revoke any whose `minted_by`\nis the leaked key's id. A key can revoke itself.",
        "operationId": "revoke_key_api_v1_keys__key_id__delete",
        "parameters": [
          {
            "name": "key_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Key Id"
            }
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Successful Response"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/credits": {
      "get": {
        "tags": [
          "credits"
        ],
        "summary": "Get Balance",
        "operationId": "get_balance_api_v1_credits_get",
        "parameters": [
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreditBalanceView"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/recipients/{email}/sms": {
      "get": {
        "tags": [
          "recipients"
        ],
        "summary": "Get Recipient Sms",
        "operationId": "get_recipient_sms_api_v1_recipients__email__sms_get",
        "parameters": [
          {
            "name": "email",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "email",
              "title": "Email"
            }
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecipientSmsView"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/recipients/{email}/phone": {
      "put": {
        "tags": [
          "recipients"
        ],
        "summary": "Set Recipient Phone",
        "operationId": "set_recipient_phone_api_v1_recipients__email__phone_put",
        "parameters": [
          {
            "name": "email",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "email",
              "title": "Email"
            }
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RecipientPhoneUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecipientSmsView"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "recipients"
        ],
        "summary": "Clear Recipient Phone",
        "operationId": "clear_recipient_phone_api_v1_recipients__email__phone_delete",
        "parameters": [
          {
            "name": "email",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "email",
              "title": "Email"
            }
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecipientSmsView"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/recipients/{email}/whatsapp/erase": {
      "post": {
        "tags": [
          "recipients"
        ],
        "summary": "Erase Recipient Whatsapp",
        "description": "Erase one recipient's WhatsApp history for your business. **Not undoable.**\n\nThe API half of an Article 17 request: a customer asks *you* to delete what they wrote, and\nthis honours it without anyone emailing us. It deletes their stored replies, removes the copies\nof those replies from any `whatsapp.inbound_message` webhook delivery still queued for your\nendpoint, drops the number we hold to stop our automatic acknowledgement repeating itself, and\n**withdraws WhatsApp consent permanently for your business**: the four together, because\nerasing the conversation while leaving the channel open to message them again is not a coherent\nstate. Nothing here is restorable through this API.\n\nScoped to your own recipients: it reaches nothing of another merchant's, including the same\nperson's conversation with them, and a customer who wants both must ask both.\n\nIt deliberately does **not** clear their phone number (`DELETE /recipients/{email}/phone` is\nthat, and it is about SMS too), the messages *you* sent them, or your delivery log: those are\nyour records rather than their words. And it cannot reach a copy that has already left us: the\nresponse's `replies_a_forward_email_may_have_carried` is the number of erased replies your own\nforward email had already processed, and searching those mailboxes is the half of the request\nonly you can complete.\n\nNeeds an `Idempotency-Key` like every other write here, and it earns its keep on a verb whose\n*effect* is already idempotent but whose *answer* is not: a second erasure truthfully reports\nzero of everything, so a merchant retrying a timed-out request with a fresh key would read \"0\ncopies may have reached your mailbox\" and stop looking. Retrying with the **same** key replays\nthe original counts. Reusing a key for a genuinely new erasure of the same recipient inside 24\nhours replays instead of erasing, so use a fresh key when you mean a fresh request;\n`first_erased_at` says which situation you are in either way.\n\n404 if this address is not one of your recipients, decided before anything is written, so a\ntypo leaves your key unspent.",
        "operationId": "erase_recipient_whatsapp_api_v1_recipients__email__whatsapp_erase_post",
        "parameters": [
          {
            "name": "email",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "email",
              "title": "Email"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours.",
              "title": "Idempotency-Key"
            },
            "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours."
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WhatsAppErasureReceipt"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/recipients/{email}/erase": {
      "post": {
        "tags": [
          "recipients"
        ],
        "summary": "Erase Recipient",
        "description": "Erase everything we hold about one of your recipients for your business. **Not undoable.**\n\nThe API half of an Article 17 request made to you. It deletes the messages you sent them (with\ntheir delivery events and SMS and WhatsApp send records), their WhatsApp replies, their\nrecipient record (phone number, SMS and WhatsApp consent, unsubscribe state), and the copies of\nthose in webhook deliveries still queued for your endpoint and in stored API responses. Sends\nstill waiting to go out for those messages are cancelled.\n\nIt keeps only what stops them being contacted again: email suppressions (bounces, complaints,\nand their unsubscribe if they had unsubscribed from you) and SMS or WhatsApp opt-outs for their\nnumber. The response counts both. An erasure does not undo an unsubscribe: if you message the\naddress again, the message is stored but no email, SMS or WhatsApp message announces it. A\nbrowser notification the recipient turned on for themselves in their inbox may still reach\nthem. An erased recipient who had not unsubscribed is messaged normally. Other businesses' records\nabout the same person are untouched, and the response says nothing about them.\n\nCopies that have already left us are yours to erase: webhook deliveries your endpoint received,\nand WhatsApp replies your forward email sent to your mailboxes\n(`replies_a_forward_email_may_have_carried`).\n\nNeeds an `Idempotency-Key`: a repeat erasure truthfully reports zeros, so retrying a timed-out\nrequest with the **same** key is how you get the original counts back. An address you never\nmessaged also answers with zeros, so this cannot be used to test whether an address is on your\nlist.",
        "operationId": "erase_recipient_api_v1_recipients__email__erase_post",
        "parameters": [
          {
            "name": "email",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "email",
              "title": "Email"
            }
          },
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": true,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours.",
              "title": "Idempotency-Key"
            },
            "description": "**Required.** A unique caller-generated string (a UUID is ideal) making this request safe to retry. Repeating a request with the same key replays the original response (including an error) instead of storing the message a second time. Scoped to your business and remembered for 24 hours."
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RecipientErasureReport"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/webhook-endpoints": {
      "get": {
        "tags": [
          "webhook-endpoints"
        ],
        "summary": "List Endpoints",
        "operationId": "list_endpoints_api_v1_webhook_endpoints_get",
        "parameters": [
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookEndpointListResponse"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "webhook-endpoints"
        ],
        "summary": "Create Endpoint",
        "description": "Register an endpoint. **The signing secret in this response is shown only once.**",
        "operationId": "create_endpoint_api_v1_webhook_endpoints_post",
        "parameters": [
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEndpointCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NewWebhookEndpointView"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/webhook-endpoints/{public_id}": {
      "delete": {
        "tags": [
          "webhook-endpoints"
        ],
        "summary": "Delete Endpoint",
        "operationId": "delete_endpoint_api_v1_webhook_endpoints__public_id__delete",
        "parameters": [
          {
            "name": "public_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Public Id"
            }
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Successful Response"
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/webhook-endpoints/{public_id}/test": {
      "post": {
        "tags": [
          "webhook-endpoints"
        ],
        "summary": "Test Endpoint",
        "description": "Send one sample ``ping`` event to this endpoint and report what came back.\n\n**200 whatever the endpoint answers.** The failure being reported belongs to the *next* hop,\nand mapping it onto this response's status would make a working BeaconBox look broken and an\nunreachable customer endpoint look like our outage. Read ``delivered``.",
        "operationId": "test_endpoint_api_v1_webhook_endpoints__public_id__test_post",
        "parameters": [
          {
            "name": "public_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Public Id"
            }
          },
          {
            "name": "authorization",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "title": "Authorization"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookTestResult"
                }
              }
            }
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ApiKeyItem": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id"
          },
          "name": {
            "type": "string",
            "title": "Name"
          },
          "masked": {
            "type": "string",
            "title": "Masked"
          },
          "created": {
            "type": "string",
            "format": "date-time",
            "title": "Created"
          },
          "minted_by": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Minted By",
            "description": "The id of the API key that created this key through the API, including a key created with `detach: true`. Null for a key created from the dashboard. If a key leaks, look for keys whose `minted_by` is its id: they were created with it, and a detached one keeps working after the leaked key is revoked."
          }
        },
        "type": "object",
        "required": [
          "id",
          "name",
          "masked",
          "created"
        ],
        "title": "ApiKeyItem"
      },
      "BatchItemResult": {
        "properties": {
          "index": {
            "type": "integer",
            "title": "Index",
            "description": "Position in the request, so results can be matched up."
          },
          "ok": {
            "type": "boolean",
            "title": "Ok"
          },
          "result": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/MessagePushResult"
              },
              {
                "type": "null"
              }
            ],
            "description": "The push result, when ok. Identical to a single push's response."
          },
          "error_code": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Error Code",
            "description": "Machine-readable code, when not ok."
          },
          "detail": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Detail"
          }
        },
        "type": "object",
        "required": [
          "index",
          "ok"
        ],
        "title": "BatchItemResult",
        "description": "One item's outcome. Items succeed and fail independently."
      },
      "Channel": {
        "type": "string",
        "enum": [
          "email",
          "sms",
          "whatsapp"
        ],
        "title": "Channel",
        "description": "A delivery channel a caller may request.\n\n``email`` is implicit and always sent: it carries the inbox link, so it is never optional.\nListing it is allowed and harmless."
      },
      "CreateApiKeyRequest": {
        "properties": {
          "name": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 120
              },
              {
                "type": "null"
              }
            ],
            "title": "Name"
          },
          "detach": {
            "type": "boolean",
            "title": "Detach",
            "description": "Create the key with no parent, so revoking the key that created it leaves this one working. Use it to rotate: create a detached key, move your systems to it, then revoke the old key. Without it, the new key is revoked along with the key that created it.",
            "default": false
          }
        },
        "type": "object",
        "title": "CreateApiKeyRequest",
        "description": "A new API key, created with an existing one."
      },
      "CreditBalanceView": {
        "properties": {
          "balance": {
            "type": "integer",
            "title": "Balance",
            "description": "Message credits remaining. One credit sends one message on whichever channel carries it; a single balance covers SMS today and WhatsApp when that channel opens."
          },
          "low_balance": {
            "type": "boolean",
            "title": "Low Balance",
            "description": "True below the low-balance threshold. SMS stops at zero; email is unaffected."
          },
          "threshold": {
            "type": "integer",
            "title": "Threshold"
          }
        },
        "type": "object",
        "required": [
          "balance",
          "low_balance",
          "threshold"
        ],
        "title": "CreditBalanceView",
        "description": "``GET /api/v1/credits``."
      },
      "DeliveryStatusView": {
        "properties": {
          "delivered": {
            "type": "boolean",
            "title": "Delivered"
          },
          "delivered_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Delivered At"
          },
          "opened": {
            "type": "boolean",
            "title": "Opened"
          },
          "opened_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Opened At"
          },
          "bounced": {
            "type": "boolean",
            "title": "Bounced"
          },
          "bounced_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Bounced At"
          },
          "sms": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/SmsDeliveryView"
              },
              {
                "type": "null"
              }
            ]
          },
          "not_sent": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/NotSentView"
              },
              {
                "type": "null"
              }
            ]
          },
          "failed": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/FailedView"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "type": "object",
        "required": [
          "delivered",
          "delivered_at",
          "opened",
          "opened_at",
          "bounced",
          "bounced_at"
        ],
        "title": "DeliveryStatusView",
        "description": "Delivery/open state of a message, derived from its events (public read API)."
      },
      "EmailOutcome": {
        "properties": {
          "sending": {
            "type": "boolean",
            "title": "Sending",
            "description": "Whether we expect to email this nudge. False is reliable; true is an expectation that a later check can still overturn."
          },
          "skipped_reason": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Skipped Reason",
            "description": "Why no email is expected, as known at push time. One of: sending_paused, business_inactive, plan_lapsed, unsubscribed, suppressed, platform_suppressed. Null when sending. `business_inactive` means the business has been deactivated. `suppressed` is this business's own list; `platform_suppressed` is an address another business already proved undeliverable, which gates the shared sending domain only. Treat an unrecognised value as 'not sending' rather than as an error ; the list grows."
          }
        },
        "type": "object",
        "required": [
          "sending"
        ],
        "title": "EmailOutcome",
        "description": "The email side of a push: whether we expect to send the nudge, and why not if we don't.\n\n**Its own vocabulary, like SMS and WhatsApp have theirs**, and that is the point rather than a\nduplication. Suppression is per channel and per reason: a recipient who texted STOP has\nsuppressed SMS and nothing else, a hard bounce suppresses email and nothing else, and a\nWhatsApp opt-out is a third independent fact. Folding them into one \"suppressed\" flag would\ntell a caller that a channel they never asked about is blocked, and leave them unable to see\nwhich one actually is.\n\n**Advisory, not authoritative, and the distinction is deliberate.** This is the gate's verdict\n*as known at push time*, so a caller learns about the stable, recipient-shaped reasons (a\nsuppressed address, an unsubscribe, a lapsed plan) in the response to the call they just\nmade, rather than only from a webhook they may not have configured. It cannot see the reasons\nthat only exist later: the day's cap filling up between now and the send, a pause applied in\nbetween, a retraction. The nudge is queued and re-checked by the worker regardless, which\nremains the single writer of delivery events.\n\nSo: ``sending=false`` here is reliable; we will not email. ``sending=true`` is a strong\nexpectation, not a promise. ``delivery.not_sent`` on the message and the ``message.not_sent``\nwebhook are what actually happened."
      },
      "FailedView": {
        "properties": {
          "reason": {
            "type": "string",
            "title": "Reason",
            "description": "esp_refused (the email provider rejected it outright), retries_exhausted (every attempt failed and BeaconBox stopped trying) or outcome_unknown (the provider stopped answering mid-send, so it may have arrived; BeaconBox will not send it again, and once the provider reports it delivered, delivered is true and failed is null, whichever of the two is timestamped first). Treat an unrecognised value as 'failed' rather than as an error; the list grows."
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "title": "At",
            "description": "When the send was given up on."
          }
        },
        "type": "object",
        "required": [
          "reason",
          "at"
        ],
        "title": "FailedView",
        "description": "Present when the email was attempted and will never be delivered.\n\n``not_sent``'s counterpart at the other end of the send path: that one means we decided not to\ntry, this one means we tried and the provider refused it or every retry failed. Either way a\ncaller polling for delivery can stop waiting. Absent is the ordinary case."
      },
      "HTTPValidationError": {
        "properties": {
          "detail": {
            "items": {
              "$ref": "#/components/schemas/ValidationError"
            },
            "type": "array",
            "title": "Detail"
          }
        },
        "type": "object",
        "title": "HTTPValidationError"
      },
      "KeyListResponse": {
        "properties": {
          "items": {
            "items": {
              "$ref": "#/components/schemas/ApiKeyItem"
            },
            "type": "array",
            "title": "Items"
          }
        },
        "type": "object",
        "required": [
          "items"
        ],
        "title": "KeyListResponse"
      },
      "MessageBatchPush": {
        "properties": {
          "items": {
            "items": {
              "$ref": "#/components/schemas/MessagePush"
            },
            "type": "array",
            "maxItems": 100,
            "minItems": 1,
            "title": "Items"
          }
        },
        "type": "object",
        "required": [
          "items"
        ],
        "title": "MessageBatchPush",
        "description": "Body of ``POST /api/v1/messages/batch``.\n\nA list of complete pushes, not one message fanned out to many recipients. Order updates are\nindividual by nature (fifty orders means fifty tracking numbers), so a shared body would only\nserve the marketing blast this product deliberately isn't for."
      },
      "MessageBatchResult": {
        "properties": {
          "items": {
            "items": {
              "$ref": "#/components/schemas/BatchItemResult"
            },
            "type": "array",
            "title": "Items"
          },
          "succeeded": {
            "type": "integer",
            "title": "Succeeded"
          },
          "failed": {
            "type": "integer",
            "title": "Failed"
          }
        },
        "type": "object",
        "required": [
          "items",
          "succeeded",
          "failed"
        ],
        "title": "MessageBatchResult",
        "description": "What a batch returns once it is processed: check ``items``, not the status code (a 409 means\nanother run of the same batch is live).\n\nThe HTTP status describes whether the batch was processed, which it was. An item that failed is\nreported, not raised: one bad recipient must not discard the forty-nine good pushes alongside\nit, and a 4xx for the whole call would invite a retry of all fifty."
      },
      "MessageKind": {
        "type": "string",
        "enum": [
          "one_off",
          "updateable"
        ],
        "title": "MessageKind"
      },
      "MessageListResponse": {
        "properties": {
          "items": {
            "items": {
              "$ref": "#/components/schemas/MessageStatusView"
            },
            "type": "array",
            "title": "Items"
          },
          "next_cursor": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Next Cursor"
          }
        },
        "type": "object",
        "required": [
          "items",
          "next_cursor"
        ],
        "title": "MessageListResponse"
      },
      "MessagePush": {
        "properties": {
          "recipient_email": {
            "type": "string",
            "format": "email",
            "title": "Recipient Email"
          },
          "recipient_phone": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Recipient Phone",
            "description": "Mobile number for the SMS nudge, E.164 preferred (e.g. +372 5555 0134). Stored against this recipient for your business and reused on later pushes, so it is optional once you have sent it once, or set it via PUT /recipients/{email}/phone."
          },
          "channels": {
            "anyOf": [
              {
                "items": {
                  "$ref": "#/components/schemas/Channel"
                },
                "type": "array"
              },
              {
                "type": "null"
              }
            ],
            "title": "Channels",
            "description": "Which paid channels this one push may use. Unset follows your account's channel settings. Naming \"sms\" or \"whatsapp\" asks for that channel; leaving one out of the list suppresses it for this push; listing both may send two messages and cost two credits. Email is always sent regardless. Naming a channel can only narrow, never switch one on: a channel set to off, or not yet enabled for your account, stays silent and the outcome reports the skip reason. A channel in on_request mode sends only when the push names it. A request never overrides a country restriction, a recipient who has opted out, or (on WhatsApp) a recipient who never opted in."
          },
          "whatsapp_opt_in": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/WhatsAppOptIn"
              },
              {
                "type": "null"
              }
            ],
            "description": "Record that this recipient agreed to be messaged on WhatsApp, as part of the push that carries them. Independent of `channels`: recording consent is not sending, so this works while your WhatsApp channel is still switched off, and a push that both records consent and asks for `whatsapp` sends on the consent it just recorded. The result is reported in `whatsapp_opt_in` on the response and never fails the push, except when there is no phone number for it to be consent about, which is refused before anything is stored. Consent is per business: an opt-in you record is yours and reaches no other merchant."
          },
          "sms_if_whatsapp_fails": {
            "type": "boolean",
            "title": "Sms If Whatsapp Fails",
            "description": "Text this recipient if the WhatsApp nudge proves *undeliverable*: Meta refuses it (the usual case: the number is not on WhatsApp), the delivery status comes back failed, or no status arrives at all within five minutes. It does **not** fire for a message that was delivered and not read: a read receipt is not delivery, and someone who read it on another device would get the same update twice. The SMS costs its own credit and appears as its own send. It also texts at once when WhatsApp cannot carry the message at all: the number is already known not to be on WhatsApp (`not_reachable`), or the push has no template (`template_not_sendable` because it lacks `reference` or `order_status`, or the reference or your business name cannot be shown); not while a template is still in review at Meta. Otherwise ignored unless a WhatsApp message was actually queued, and skipped when this push already queued an SMS of its own; at most one text either way. With `escalate_if_unread_after_minutes`, the fallback is held with the WhatsApp message: nothing is sent on either channel unless the message is still unread at the deadline, including for a number already known not to be on WhatsApp.",
            "default": false
          },
          "subject": {
            "type": "string",
            "maxLength": 300,
            "minLength": 1,
            "title": "Subject"
          },
          "reference": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 32
              },
              {
                "type": "null"
              }
            ],
            "title": "Reference",
            "description": "Your own order number for what this message is about, e.g. #A-10294. Optional. Letters, digits and # - _ . / only, no spaces. It is shown in the WhatsApp and SMS nudges, so it must be an identifier rather than a sentence. A nudge shows it only when it starts with a letter or digit (after an optional #), holds only letters, digits and _ / # -, and has at most two dots, each directly before a digit (INV.2026): anything a phone could turn into a link, such as shop.com, is stored and echoed but never shown, so such a push sends no WhatsApp message."
          },
          "order_status": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/OrderStatus"
              },
              {
                "type": "null"
              }
            ],
            "description": "What happened to the order this message is about. Optional. One of: confirmed, shipped, ready_for_pickup, delivered, cancelled, refunded, delayed. It chooses the wording of the WhatsApp and SMS nudges, which BeaconBox writes (for example \"Your order #A-10294 from PhonicBloom has shipped.\"): your subject and body never appear in either. A WhatsApp nudge is sent only when the push carries both `reference` and `order_status`, because Meta accepts an order notification only when it says what happened to which order; without them WhatsApp is skipped with `template_not_sendable` and nothing is charged. An in-place update replaces it, like `reference`. More values may be added, so treat one you do not recognise as no status."
          },
          "body": {
            "type": "string",
            "maxLength": 10000,
            "minLength": 1,
            "title": "Body",
            "description": "Plain text and clickable links only. At most 10,000 characters, which is a ceiling rather than a target: an update longer than that is a document, and the message is the thing that tells someone to come and look."
          },
          "kind": {
            "$ref": "#/components/schemas/MessageKind",
            "description": "one_off: always a new message + nudge. updateable: matched on subject; creates + nudges, or updates in place quietly.",
            "default": "one_off"
          },
          "notify": {
            "anyOf": [
              {
                "type": "boolean"
              },
              {
                "type": "null"
              }
            ],
            "title": "Notify",
            "description": "Override the default nudge behaviour. Unset applies the default (create → nudge, in-place update → silent). true forces a nudge even on an in-place update; false suppresses the nudge even on first create."
          },
          "send_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Send At",
            "description": "Hold the nudge until this moment (RFC 3339, **timezone required**). The message is stored and readable immediately; only the email/SMS ping waits, because the inbox is the source of truth and the nudge is what should land at a civilised hour. Must be in the future and at most 90 days out. Ignored when the push sends no nudge (notify: false, or a quiet in-place update)."
          },
          "escalate_if_unread_after_minutes": {
            "anyOf": [
              {
                "type": "integer",
                "maximum": 10080,
                "minimum": 5
              },
              {
                "type": "null"
              }
            ],
            "title": "Escalate If Unread After Minutes",
            "description": "**Hold the paid channel back until the recipient has had a chance to read the email.** With this set, the SMS or WhatsApp message named in `channels` is not sent now; it is sent only if the recipient has still not opened the message in their inbox after this many minutes (5 minutes to 7 days). If they open it first, nothing is sent and nothing is charged.\n\nThe minutes count from the email: from `send_at` when you set one, otherwise from the push. If the email has still not gone out at the deadline, the deadline moves to this many minutes after it does. An email that bounced, failed or was skipped (for example, the address is suppressed) counts as unread, so the paid channel is still sent, subject to its own consent and settings.\n\nThat inverts what a paid channel usually costs you: instead of paying to interrupt everybody, you pay only for the people the email did not reach. `escalates_at` on the channel's block says when the deadline falls.\n\nIgnored when the push sends no nudge, and when no paid channel was requested."
          },
          "obsoletes": {
            "items": {
              "type": "string",
              "maxLength": 64,
              "minLength": 1
            },
            "type": "array",
            "maxItems": 100,
            "title": "Obsoletes",
            "description": "public_ids of the caller's earlier messages to mark obsolete (grey out). At most 100."
          }
        },
        "type": "object",
        "required": [
          "recipient_email",
          "subject",
          "body"
        ],
        "title": "MessagePush",
        "description": "Body of ``POST /api/v1/messages`` (business → BeaconBox)."
      },
      "MessagePushResult": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id"
          },
          "recipient_email": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Recipient Email",
            "description": "The recipient this message was stored for, as we normalised it (trimmed, lower case). Null only when replaying a response recorded before this field was added."
          },
          "subject": {
            "type": "string",
            "title": "Subject"
          },
          "kind": {
            "$ref": "#/components/schemas/MessageKind"
          },
          "status": {
            "$ref": "#/components/schemas/MessageStatus"
          },
          "created": {
            "type": "boolean",
            "title": "Created",
            "description": "True if a new message was created, False if updated in place."
          },
          "scheduled_for": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Scheduled For",
            "description": "When the nudge will be sent, if it was deferred with send_at. Null means it was queued immediately (or that nothing was nudged)."
          },
          "nudged": {
            "type": "boolean",
            "title": "Nudged",
            "description": "Whether a nudge was queued for delivery. Actual delivery/bounce arrives asynchronously via webhook; query the message status to see it."
          },
          "email": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/EmailOutcome"
              },
              {
                "type": "null"
              }
            ],
            "description": "Present when an email nudge was queued for this push. Absent when none was : an update that does not re-nudge, or `notify=false`, because there is then no email outcome to report and the caller already knows why."
          },
          "sms": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/SmsOutcome"
              },
              {
                "type": "null"
              }
            ],
            "description": "Present when SMS was in play for this push, either queued or explicitly refused. Absent when the account has SMS off and the request didn't ask for it."
          },
          "sms_units": {
            "type": "integer",
            "title": "Sms Units",
            "description": "Carrier segments the SMS costs. Always 1 when one was queued (the nudge is fixed-length), 0 otherwise. Charged credits may differ by destination; see sms.credits.",
            "default": 0
          },
          "whatsapp": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/WhatsAppOutcome"
              },
              {
                "type": "null"
              }
            ],
            "description": "Present when WhatsApp was in play for this push, either queued or explicitly refused. Absent when the account has WhatsApp off and the request didn't ask for it."
          },
          "whatsapp_opt_in": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/WhatsAppOptInOutcome"
              },
              {
                "type": "null"
              }
            ],
            "description": "Present exactly when you sent `whatsapp_opt_in`, and absent otherwise. Deliberately top-level rather than inside `whatsapp`: recording consent is not sending, and the `whatsapp` block is absent for an account with the channel switched off, which is precisely the account collecting consent ahead of turning it on."
          },
          "reference": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Reference",
            "description": "The order reference you sent, echoed back. Null if you sent none."
          },
          "order_status": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Order Status",
            "description": "The order status you sent, echoed back. Null if you sent none. A plain string rather than the request's enum, so a status added later never breaks a client reading it."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "title": "Created At"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "title": "Updated At"
          }
        },
        "type": "object",
        "required": [
          "id",
          "subject",
          "kind",
          "status",
          "created",
          "nudged",
          "created_at",
          "updated_at"
        ],
        "title": "MessagePushResult",
        "description": "What the business gets back after a push."
      },
      "MessageStatus": {
        "type": "string",
        "enum": [
          "active",
          "obsolete",
          "retracted"
        ],
        "title": "MessageStatus"
      },
      "MessageStatusView": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id"
          },
          "recipient_email": {
            "type": "string",
            "title": "Recipient Email"
          },
          "subject": {
            "type": "string",
            "title": "Subject"
          },
          "kind": {
            "$ref": "#/components/schemas/MessageKind"
          },
          "status": {
            "$ref": "#/components/schemas/MessageStatus"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "title": "Created At"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time",
            "title": "Updated At"
          },
          "delivery": {
            "$ref": "#/components/schemas/DeliveryStatusView"
          }
        },
        "type": "object",
        "required": [
          "id",
          "recipient_email",
          "subject",
          "kind",
          "status",
          "created_at",
          "updated_at",
          "delivery"
        ],
        "title": "MessageStatusView",
        "description": "A business's message plus its delivery status (public read API)."
      },
      "NewKeyResponse": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id"
          },
          "name": {
            "type": "string",
            "title": "Name"
          },
          "key": {
            "type": "string",
            "title": "Key"
          },
          "masked": {
            "type": "string",
            "title": "Masked"
          },
          "created": {
            "type": "string",
            "format": "date-time",
            "title": "Created"
          }
        },
        "type": "object",
        "required": [
          "id",
          "name",
          "key",
          "masked",
          "created"
        ],
        "title": "NewKeyResponse",
        "description": "The response to a mint. ``key`` is the only time the full value exists outside the caller.\n\n``id`` is here because without it **the caller cannot revoke the key they just created.** The\nonly other way to find it was to list the keys and match on ``name``, and ``name`` carries no\nunique constraint and defaults to \"Untitled key\", so two unnamed keys are indistinguishable and\nthe match picks whichever the ordering happens to put first. That is a wrong-key revocation on\nthe one path that exists to answer a leak.\n\nNot a disclosure: ``ApiKeyItem.id`` is the same value and ``GET /keys`` has always returned it."
      },
      "NewWebhookEndpointView": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id"
          },
          "url": {
            "type": "string",
            "title": "Url"
          },
          "description": {
            "type": "string",
            "title": "Description"
          },
          "secret": {
            "type": "string",
            "title": "Secret",
            "description": "Masked. The full value is shown only when the endpoint is created."
          },
          "event_types": {
            "items": {
              "type": "string"
            },
            "type": "array",
            "title": "Event Types",
            "description": "Empty means every event type."
          },
          "disabled": {
            "type": "boolean",
            "title": "Disabled",
            "description": "True once too many consecutive deliveries have exhausted their retries. Delete and re-create the endpoint once the receiver is healthy again."
          },
          "consecutive_failures": {
            "type": "integer",
            "title": "Consecutive Failures"
          },
          "last_success_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Last Success At"
          },
          "last_error": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Last Error"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "title": "Created At"
          }
        },
        "type": "object",
        "required": [
          "id",
          "url",
          "description",
          "secret",
          "event_types",
          "disabled",
          "consecutive_failures",
          "last_success_at",
          "last_error",
          "created_at"
        ],
        "title": "NewWebhookEndpointView",
        "description": "The creation response. ``secret`` here is the real one, and is never shown again."
      },
      "NotSentView": {
        "properties": {
          "reason": {
            "type": "string",
            "title": "Reason",
            "description": "Why nothing was emailed: plan_lapsed, daily_cap_reached, suppressed, platform_suppressed, unsubscribed, sending_paused, business_inactive, message_retracted, or message_obsolete. Treat an unrecognised value as 'not sent' rather than as an error; the list grows. daily_cap_reached is final for this send: the nudge is not retried when the cap resets, so resend it (or push again) the next day if it still matters."
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "title": "At",
            "description": "When the send path decided against it."
          }
        },
        "type": "object",
        "required": [
          "reason",
          "at"
        ],
        "title": "NotSentView",
        "description": "Present when we decided not to email this message at all, and it still stands.\n\n**The field that makes ``delivered: false`` readable.** Without it that flag meant two\nopposite things (*on its way* and *never attempted, and never will be*), and a caller\npolling for delivery had no way to stop waiting. Absent is the ordinary case."
      },
      "OrderStatus": {
        "type": "string",
        "enum": [
          "confirmed",
          "shipped",
          "ready_for_pickup",
          "delivered",
          "cancelled",
          "refunded",
          "delayed"
        ],
        "title": "OrderStatus"
      },
      "RecipientErasureReport": {
        "properties": {
          "messages_deleted": {
            "type": "integer",
            "title": "Messages Deleted",
            "description": "Messages you sent them, subjects and bodies included."
          },
          "message_events_deleted": {
            "type": "integer",
            "title": "Message Events Deleted",
            "description": "Delivery, open and not-sent events about those messages."
          },
          "channel_sends_deleted": {
            "type": "integer",
            "title": "Channel Sends Deleted",
            "description": "SMS and WhatsApp send records, with the number they went to. Your credit ledger keeps its entries; they no longer point at a send."
          },
          "message_links_deleted": {
            "type": "integer",
            "title": "Message Links Deleted",
            "description": "Links that opened one of those messages."
          },
          "whatsapp_replies_deleted": {
            "type": "integer",
            "title": "Whatsapp Replies Deleted",
            "description": "WhatsApp replies they sent you, bodies included."
          },
          "webhook_payloads_scrubbed": {
            "type": "integer",
            "title": "Webhook Payloads Scrubbed",
            "description": "Webhook deliveries of ours about this person whose address, subject, number or reply text we replaced with null. A delivery still queued for your endpoint will arrive with those fields empty. Copies your endpoint already received are yours to delete."
          },
          "queued_jobs_cancelled": {
            "type": "integer",
            "title": "Queued Jobs Cancelled",
            "description": "Nudges, texts and escalations still waiting to go out for those messages, now cancelled."
          },
          "idempotency_records_deleted": {
            "type": "integer",
            "title": "Idempotency Records Deleted",
            "description": "Stored API responses that named this recipient. Retrying one of those requests with its original `Idempotency-Key` will run it again rather than replay it."
          },
          "audit_entries_pseudonymised": {
            "type": "integer",
            "title": "Audit Entries Pseudonymised"
          },
          "subscription_deleted": {
            "type": "boolean",
            "title": "Subscription Deleted",
            "description": "Whether their recipient record (phone number, SMS and WhatsApp consent, unsubscribe state) was deleted. False when there was none, for example on a repeat erasure. An unsubscribe it held is kept first, as a suppression (`suppressions_kept`)."
          },
          "stops_kept": {
            "type": "integer",
            "title": "Stops Kept",
            "description": "SMS or WhatsApp opt-outs for their number that we keep, so that nobody can text them again by pushing to this address later. Kept on purpose: an opt-out that is erased on request stops being one."
          },
          "suppressions_kept": {
            "type": "integer",
            "title": "Suppressions Kept",
            "description": "Email suppressions for this address that we keep, for the same reason: a hard bounce, a complaint, or their unsubscribe from your business, which would otherwise have gone with their recipient record. At most one, with one reason."
          },
          "auto_reply_windows_dropped": {
            "type": "integer",
            "title": "Auto Reply Windows Dropped",
            "description": "Rows holding their number so our automatic WhatsApp acknowledgement would not repeat."
          },
          "replies_a_forward_email_may_have_carried": {
            "type": "integer",
            "title": "Replies A Forward Email May Have Carried",
            "description": "An upper bound on erased WhatsApp replies that your reply-forward email had already sent to your admin users' mailboxes. Those copies are outside anything we can reach: search your own mail for this recipient."
          }
        },
        "type": "object",
        "required": [
          "messages_deleted",
          "message_events_deleted",
          "channel_sends_deleted",
          "message_links_deleted",
          "whatsapp_replies_deleted",
          "webhook_payloads_scrubbed",
          "queued_jobs_cancelled",
          "idempotency_records_deleted",
          "audit_entries_pseudonymised",
          "subscription_deleted",
          "stops_kept",
          "suppressions_kept",
          "auto_reply_windows_dropped",
          "replies_a_forward_email_may_have_carried"
        ],
        "title": "RecipientErasureReport",
        "description": "What one erasure removed from your records about one recipient. **Not undoable.**"
      },
      "RecipientPhoneUpdate": {
        "properties": {
          "phone": {
            "type": "string",
            "maxLength": 32,
            "minLength": 4,
            "title": "Phone",
            "description": "Mobile number, E.164 preferred (e.g. +372 5555 0134)."
          }
        },
        "type": "object",
        "required": [
          "phone"
        ],
        "title": "RecipientPhoneUpdate"
      },
      "RecipientSmsView": {
        "properties": {
          "recipient_email": {
            "type": "string",
            "title": "Recipient Email"
          },
          "phone": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Phone",
            "description": "Masked, e.g. \"+372 •••• 0134\". None if no number is stored."
          },
          "sms_status": {
            "$ref": "#/components/schemas/SmsRecipientStatus"
          },
          "sms_status_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Sms Status At"
          },
          "sms_consent_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Sms Consent At",
            "description": "When a number was first accepted for texting this recipient. Null for a recipient who has never had one, or whose number predates this field."
          },
          "sms_consent_source": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Sms Consent Source",
            "description": "The surface that first accepted it: \"push\" (recipient_phone on a push), \"api\" (PUT /recipients/{email}/phone), \"admin\" (the recipient page) or \"inbox\"."
          }
        },
        "type": "object",
        "required": [
          "recipient_email",
          "phone",
          "sms_status",
          "sms_status_at"
        ],
        "title": "RecipientSmsView",
        "description": "A recipient's SMS state. The number is masked: reads never return it in full."
      },
      "RetractResult": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id"
          },
          "status": {
            "$ref": "#/components/schemas/MessageStatus",
            "description": "The message's status now. `retracted` unless a later push had already superseded it, in which case `obsolete` stands: a newer update really does replace it."
          },
          "retracted": {
            "type": "boolean",
            "title": "Retracted",
            "description": "True if this call is what withdrew it. False means it was already not live (you have retracted it before, or a later push superseded it), not that anything failed."
          },
          "sends_cancelled": {
            "type": "integer",
            "title": "Sends Cancelled",
            "description": "Queued SMS/WhatsApp sends this call called off. Zero means there was nothing left to stop: none was queued, they had already gone, or (in the narrow case where a worker had already charged for one) you were a moment too late. Retrying the same Idempotency-Key replays this number rather than recounting it."
          },
          "already_notified": {
            "type": "boolean",
            "title": "Already Notified",
            "description": "Whether a nudge for this message had already reached a provider before you retracted. True means the recipient was told: the message is marked withdrawn in their inbox, but an email or text about it is already out and cannot be recalled."
          }
        },
        "type": "object",
        "required": [
          "id",
          "status",
          "retracted",
          "sends_cancelled",
          "already_notified"
        ],
        "title": "RetractResult",
        "description": "What the business gets back after taking a message back.\n\nThree of the four fields are facts about the *message*, so they read the same on a replay. Only\n``sends_cancelled`` describes this particular call; see its own note."
      },
      "SmsDeliveryView": {
        "properties": {
          "status": {
            "type": "string",
            "title": "Status",
            "description": "queued, accepted, delivered, failed, rejected, or skipped. 'accepted' means the carrier took it; 'delivered' means the handset confirmed it."
          },
          "skipped_reason": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Skipped Reason"
          },
          "credits_charged": {
            "type": "integer",
            "title": "Credits Charged"
          },
          "submitted_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Submitted At"
          },
          "last_status_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Last Status At"
          }
        },
        "type": "object",
        "required": [
          "status",
          "credits_charged",
          "submitted_at",
          "last_status_at"
        ],
        "title": "SmsDeliveryView",
        "description": "The SMS side of a message's delivery, present only when one was queued."
      },
      "SmsOutcome": {
        "properties": {
          "queued": {
            "type": "boolean",
            "title": "Queued"
          },
          "credits": {
            "type": "integer",
            "title": "Credits",
            "description": "Credits this SMS will cost when a carrier accepts it. One per message: the nudge is a fixed single carrier segment, adjusted for the destination country."
          },
          "skipped_reason": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Skipped Reason",
            "description": "Why no SMS was queued. One of: sending_paused, unsubscribed, disabled_by_request, sms_not_enabled, sms_disabled, country_not_allowed, no_phone, phone_conflict, stopped, insufficient_credit, rate_limited, sms_warmup_cap_reached. `sms_not_enabled` means SMS has not been switched on for your business yet; ask for it from the dashboard. `sms_disabled` means your SMS mode is off; a push that does not mention SMS on an account that has it off, or sends it only on request, gets no `sms` block at all. `sms_warmup_cap_reached` is decided when the send runs, so it reaches you on the message rather than in this response. Treat an unrecognised value as 'not queued' rather than as an error; the list grows."
          },
          "escalates_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Escalates At",
            "description": "Set when `escalate_if_unread_after_minutes` armed this channel instead of sending it. `queued` is false and there is no `skipped_reason`: nothing was refused, and nothing has been charged. At this moment we check whether the recipient has opened the message, and send only if they have not."
          }
        },
        "type": "object",
        "required": [
          "queued",
          "credits"
        ],
        "title": "SmsOutcome",
        "description": "The SMS side of a push, and the *only* way an SMS problem is ever reported.\n\n``queued=False`` with a ``skipped_reason`` is a normal, successful outcome: the message is in\nthe recipient's inbox and the email nudge went out regardless. A push never fails because of\nSMS: SMS is additive, so reporting it as a failed request would misdescribe what happened and\ninvite a retry that stores the update twice. Check this object, not the status code."
      },
      "SmsRecipientStatus": {
        "type": "string",
        "enum": [
          "none",
          "active",
          "stopped"
        ],
        "title": "SmsRecipientStatus"
      },
      "ValidationError": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          },
          "input": {
            "title": "Input"
          },
          "ctx": {
            "type": "object",
            "title": "Context"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      },
      "WebhookEndpointCreate": {
        "properties": {
          "url": {
            "type": "string",
            "maxLength": 500,
            "title": "Url",
            "description": "HTTPS URL to POST events to. Must resolve to a public address: private, loopback and link-local addresses are rejected, and redirects are never followed."
          },
          "description": {
            "type": "string",
            "maxLength": 120,
            "title": "Description",
            "description": "Your own label for this endpoint.",
            "default": ""
          },
          "event_types": {
            "items": {
              "$ref": "#/components/schemas/WebhookEventType"
            },
            "type": "array",
            "title": "Event Types",
            "description": "Events to receive. **Leave empty to receive all of them**, including types added later; otherwise a new event type would silently pass you by."
          }
        },
        "type": "object",
        "required": [
          "url"
        ],
        "title": "WebhookEndpointCreate"
      },
      "WebhookEndpointListResponse": {
        "properties": {
          "items": {
            "items": {
              "$ref": "#/components/schemas/WebhookEndpointView"
            },
            "type": "array",
            "title": "Items"
          }
        },
        "type": "object",
        "required": [
          "items"
        ],
        "title": "WebhookEndpointListResponse"
      },
      "WebhookEndpointView": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id"
          },
          "url": {
            "type": "string",
            "title": "Url"
          },
          "description": {
            "type": "string",
            "title": "Description"
          },
          "secret": {
            "type": "string",
            "title": "Secret",
            "description": "Masked. The full value is shown only when the endpoint is created."
          },
          "event_types": {
            "items": {
              "type": "string"
            },
            "type": "array",
            "title": "Event Types",
            "description": "Empty means every event type."
          },
          "disabled": {
            "type": "boolean",
            "title": "Disabled",
            "description": "True once too many consecutive deliveries have exhausted their retries. Delete and re-create the endpoint once the receiver is healthy again."
          },
          "consecutive_failures": {
            "type": "integer",
            "title": "Consecutive Failures"
          },
          "last_success_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Last Success At"
          },
          "last_error": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Last Error"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "title": "Created At"
          }
        },
        "type": "object",
        "required": [
          "id",
          "url",
          "description",
          "secret",
          "event_types",
          "disabled",
          "consecutive_failures",
          "last_success_at",
          "last_error",
          "created_at"
        ],
        "title": "WebhookEndpointView",
        "description": "An endpoint as listed. The secret is masked; it is returned in full exactly once."
      },
      "WebhookEventType": {
        "type": "string",
        "enum": [
          "message.delivered",
          "message.opened",
          "message.bounced",
          "message.failed",
          "message.complained",
          "message.not_sent",
          "sms.delivered",
          "sms.failed",
          "sms.rejected",
          "whatsapp.delivered",
          "whatsapp.failed",
          "whatsapp.rejected",
          "whatsapp.read",
          "whatsapp.inbound_message",
          "credits.low_balance",
          "plan.past_due",
          "plan.lapsed",
          "plan.allowance_exceeded"
        ],
        "title": "WebhookEventType",
        "description": "What a business can subscribe to.\n\nDeliberately only *outcomes the sender cannot already know*. There is no ``message.created``:\nthe caller just made that call and got the message back in the response, so an event for it is\na round trip that tells them nothing. ``sent`` is likewise absent: it means \"handed to the\nESP\", which is BeaconBox bookkeeping (it drives the per-day cap), not a delivery outcome.\n\nStored as plain strings rather than a Postgres enum: subscriptions live in an array column, and\na new event type should be a deploy, not a migration plus an irreversible ``ALTER TYPE``."
      },
      "WebhookTestResult": {
        "properties": {
          "delivered": {
            "type": "boolean",
            "title": "Delivered",
            "description": "True when the endpoint answered 2xx."
          },
          "status_code": {
            "anyOf": [
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ],
            "title": "Status Code",
            "description": "What the endpoint answered. Null when it could not be reached."
          },
          "error": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Error",
            "description": "What went wrong, in words meant for the person who pressed test."
          },
          "duration_ms": {
            "type": "integer",
            "title": "Duration Ms",
            "description": "How long the endpoint took to answer."
          }
        },
        "type": "object",
        "required": [
          "delivered",
          "duration_ms"
        ],
        "title": "WebhookTestResult",
        "description": "What a manual test POST did.\n\nAlways 200: the *request* to BeaconBox succeeded whatever the endpoint answered, and a 502\nhere would describe the wrong hop. Read ``delivered``."
      },
      "WhatsAppErasureReceipt": {
        "properties": {
          "replies_deleted": {
            "type": "integer",
            "title": "Replies Deleted",
            "description": "Stored replies from this recipient that we deleted, bodies and metadata alike."
          },
          "webhook_payloads_scrubbed": {
            "type": "integer",
            "title": "Webhook Payloads Scrubbed",
            "description": "Copies of those replies removed from `whatsapp.inbound_message` webhook deliveries of ours. A delivery still queued for your endpoint now carries a null `body`; it will still tell you that a reply arrived and when."
          },
          "auto_reply_windows_dropped": {
            "type": "integer",
            "title": "Auto Reply Windows Dropped",
            "description": "Rows holding this person's number so our automatic acknowledgement would not repeat itself. Dropping them can cost them one extra automatic reply if they write in again today, which is the right side of the trade for a number we were asked to erase."
          },
          "consent_withdrawn": {
            "type": "boolean",
            "title": "Consent Withdrawn",
            "description": "True when this erasure moved them to stopped. Erasing the conversation while leaving the channel open to message them again is not a state we keep, so an erasure withdraws WhatsApp consent, **permanently for your business**. It cannot be restored through this API; you would have to collect consent again. False only when they had already stopped."
          },
          "replies_a_forward_email_may_have_carried": {
            "type": "integer",
            "title": "Replies A Forward Email May Have Carried",
            "description": "**Copies only you can delete: this number is your remaining work.** Erased replies whose reply-forward notification had already run. Where your *Forward replies to email* setting was `full` at the time, the customer's words went to your admin users' mailboxes, and an inbox, an archive, a backup and a helpdesk's search index are all outside anything we can reach. We do not record what the setting was on the day, so this is an upper bound: search your own mail for this recipient, and your own store for anything you kept from a `whatsapp.inbound_message` delivery. Until you do, the erasure is ours only."
          },
          "forward_setting": {
            "$ref": "#/components/schemas/WhatsAppReplyForward",
            "description": "Your forwarding setting **now**, so you know which mailboxes to search from here on: `full` sends the words, `link_only` sends a link to them, `off` sends nothing. It says nothing about what the setting was when the erased replies arrived."
          },
          "first_erased_at": {
            "type": "string",
            "format": "date-time",
            "title": "First Erased At",
            "description": "When this recipient was **first** erased for your business. On a first erasure this is now; on a repeat it is the earlier date and the counts above are zero because there was nothing left, not because there never was anything. Retrying with the same `Idempotency-Key` replays the original counts instead."
          }
        },
        "type": "object",
        "required": [
          "replies_deleted",
          "webhook_payloads_scrubbed",
          "auto_reply_windows_dropped",
          "consent_withdrawn",
          "replies_a_forward_email_may_have_carried",
          "forward_setting",
          "first_erased_at"
        ],
        "title": "WhatsAppErasureReceipt",
        "description": "The public API's erasure receipt: the same six facts, written for the party who *holds* the\ncopies this erasure cannot reach."
      },
      "WhatsAppOptIn": {
        "properties": {
          "source": {
            "type": "string",
            "maxLength": 60,
            "minLength": 1,
            "title": "Source",
            "description": "Where this person agreed, in your own words and at most 60 characters, e.g. \"checkout tickbox\", \"order form\", \"in-store tablet\". This is the audit answer to \"prove they agreed\", so it must name a real surface of yours rather than repeat an example."
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "source"
        ],
        "title": "WhatsAppOptIn",
        "description": "Record that this recipient agreed to be messaged on WhatsApp: an object, not a flag.\n\n**Why not ``whatsapp_opt_in: true``.** A boolean is the field an integration sets by copying an\nexample, and a copied ``true`` is not evidence of anything: it would put ``active`` on every\nrecipient a merchant ever pushes, and the answer we gave Meta when asked to evidence consent\nwould be \"their code sent us a flag\". Requiring an object with a ``source`` inside it does not\nmake the claim *true* (nothing here can), but it makes it a claim the merchant had to author,\nabout a surface they had to name, that lands verbatim in a column an auditor reads."
      },
      "WhatsAppOptInOutcome": {
        "properties": {
          "recorded": {
            "type": "boolean",
            "title": "Recorded",
            "description": "True only when this push moved the recipient to opted in."
          },
          "status": {
            "$ref": "#/components/schemas/WhatsAppRecipientStatus",
            "description": "This recipient's WhatsApp consent for your business now: none, active or stopped. A `stopped` recipient cannot be opted back in; collect consent again out of band."
          },
          "skipped_reason": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Skipped Reason",
            "description": "Why no opt-in was recorded. One of: already_opted_in (consent was already on file, with its original date and source kept), opted_out (this recipient withdrew; permanent for your business), phone_conflict (the number you sent belongs to another of your recipients, so it was not stored and consent about it was not recorded). A recipient with no number at all does not appear here; that push is refused with 422 whatsapp.phone_required before anything is stored, so your Idempotency-Key is not spent and you can retry with a number."
          }
        },
        "type": "object",
        "required": [
          "recorded",
          "status"
        ],
        "title": "WhatsAppOptInOutcome",
        "description": "What the push's ``whatsapp_opt_in`` did. Same contract as ``SmsOutcome``: reported, never\nraised.\n\n``recorded=False`` is an ordinary success in three of its four shapes: consent was already on\nfile, or the number this push carried clashed with another recipient's, or the recipient has\nwithdrawn. **The last one is the answer and not a failure**, which is why it appears here\nrather than as a 409: an opt-out is permanent for a business, so a caller told ``opted_out``\nhas learned something durable about this recipient, and a push that stored a message and sent\nan email did not fail merely because the consent they believed they held is contradicted by a\nwithdrawal we hold.\n\n``status`` is the recipient's consent *after* this push, so a caller can act on one field\ninstead of inferring state from a reason string."
      },
      "WhatsAppOutcome": {
        "properties": {
          "queued": {
            "type": "boolean",
            "title": "Queued"
          },
          "credits": {
            "type": "integer",
            "title": "Credits",
            "description": "Credits this message will cost when Meta accepts it. One per message, adjusted for the destination country, the same flat rate as SMS."
          },
          "skipped_reason": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Skipped Reason",
            "description": "Why no WhatsApp message was queued. One of: sending_paused, unsubscribed, disabled_by_request, whatsapp_not_enabled, whatsapp_disabled, country_not_allowed, no_phone, phone_conflict, not_opted_in, stopped, not_reachable, template_not_sendable, insufficient_credit, rate_limited, platform_limited. `whatsapp_not_enabled` means WhatsApp has not been switched on for your business yet. `whatsapp_disabled` means your WhatsApp mode is off. `template_not_sendable` means there is no approved template for this message: either the push did not carry both `reference` and `order_status`, or the reference or business name cannot be shown (yours to fix), or the template for its status is still in review at Meta (ours to fix). Nothing is charged either way. Treat an unrecognised value as 'not queued' rather than as an error; the list grows."
          },
          "sms_fallback": {
            "type": "boolean",
            "title": "Sms Fallback",
            "description": "Whether an SMS is armed to follow if this WhatsApp message proves undeliverable: your sms_if_whatsapp_fails, confirmed. False when you didn't ask, when nothing was queued to fail, or when this push already sent an SMS. If it fires, the SMS is a separate send with its own credit; poll the message to see it.",
            "default": false
          },
          "escalates_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Escalates At",
            "description": "Set when `escalate_if_unread_after_minutes` armed this channel instead of sending it. See `SmsOutcome.escalates_at`, same contract on both channels."
          }
        },
        "type": "object",
        "required": [
          "queued",
          "credits"
        ],
        "title": "WhatsAppOutcome",
        "description": "The WhatsApp side of a push. Same contract as ``SmsOutcome``: a reported outcome, never\na failure.\n\n``skipped_reason`` carries three values SMS has no equivalent of. ``not_opted_in`` and\n``stopped`` are separate on purpose: the first is fixable by collecting consent, the second is\nnot, and ``template_not_sendable`` is the one reason here that is *ours* rather than the\nmerchant's: the platform template is under review or has been re-categorised, and no change on\ntheir side will help."
      },
      "WhatsAppRecipientStatus": {
        "type": "string",
        "enum": [
          "none",
          "active",
          "stopped"
        ],
        "title": "WhatsAppRecipientStatus",
        "description": "WhatsApp consent, and **deliberately not** ``SmsRecipientStatus``.\n\nSame three values, a separate column and a separate enum type, because the one thing this\nmust never become is a shared status two channels both read. Holding a number for SMS says\nnothing about whether its owner agreed to be messaged on WhatsApp, Meta can audit that\nagreement, and a single column would make \"they accepted SMS\" the answer we gave."
      },
      "WhatsAppReplyForward": {
        "type": "string",
        "enum": [
          "full",
          "link_only",
          "off"
        ],
        "title": "WhatsAppReplyForward",
        "description": "How much of a customer's WhatsApp reply the notification email carries.\n\nThree values because there are genuinely three answers, and the middle one only became\nanswerable when the Recipient page learned to render a reply.\n\nEach is wrong in its own way, and which way is the merchant's to choose, which is the reason\nthis is a setting rather than a decision we make for them."
      }
    },
    "securitySchemes": {
      "ApiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "An API key, generated in the dashboard under Settings. Keys start with bbx_live_."
      }
    }
  },
  "security": [
    {
      "ApiKey": []
    }
  ]
}
