{
  "openapi": "3.1.0",
  "info": {
    "title": "iBubbl API",
    "version": "v1",
    "description": "The iBubbl REST API. Send iMessages, manage contacts, and subscribe to webhook events from your backend.\n\nAuthenticate every request with a Bearer API key (`ib_pk_<hex>`) \u2014 generate one from your dashboard's API Access settings. See the [Authentication](/authentication) guide."
  },
  "paths": {
    "/v1/messages": {
      "post": {
        "tags": [
          "Messages"
        ],
        "summary": "Send a message",
        "description": "Send a message to one contact (`contact_id`, or `to` \u2014 an E.164 phone number; the contact is created on first use), into an existing group (`chat_guid`), or to a new group (`participant_phones`). Exactly one of the four is required.\n\nThe response returns immediately with `status: \"pending\"` or `\"queued\"`. Delivery progress arrives as [webhook events](/webhooks/events) (`message.sent`, `message.delivered`, `message.read`, `message.failed`), or poll `GET /v1/messages/{id}`.\n\n**Idempotency is required.** Provide a unique `idempotency_key` per logical send (UUIDv4 recommended); a retry with the same key and payload replays the original response, while a reused key with a different payload returns `409 idempotency_conflict`. See the [Idempotency](/idempotency) guide.\n\nA send to an opted-out contact returns `422 contact_opted_out`; see [Suppression](/concepts/suppression).\n\nTo send files, list up to 10 `https` URLs in `attachments` (1:1 only); `body`, if set, is their caption. iBubbl downloads them before answering, which can take up to about 30 seconds, so allow 60 seconds before your client times out. See [Attachments](/guides/attachments).\n\nOn a `5xx` (including a `502`/`503` from the network edge during a deploy), retry with the same `idempotency_key`.",
        "operationId": "v1_send_message_v1_messages_post",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MessageCreate"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Message accepted for delivery.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Message"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "`not_found` (no contact with that `contact_id`) or `group_not_found` (no group with that `chat_guid`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "409": {
            "description": "Idempotency key reused with a different payload.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "422": {
            "description": "Request rejected; see `detail.error`. `validation_error` (bad request shape), `contact_opted_out`, `contact_blocked`, `no_eligible_line`, `unresponsive_contact`, `cold_cap_exhausted`, `followup_cap_exhausted`, `daily_cap_exhausted`, `group_outbound_blocked_suppression`, `insufficient_participants`, `reply_target_not_found`, `reply_target_unsent`, `reply_target_partly_unsent`, `first_message_needs_text`, `attachment_fetch_failed`, `attachment_too_large`, `unsupported_attachment`, or `attachment_combination_not_supported`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limited`: too many sends with attachments are being downloaded right now. Wait `Retry-After` seconds and retry with the same `idempotency_key`.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "500": {
            "description": "`no_owner_rep` (your account has no active owner rep to send as) or `internal_error`. Retry with the same `idempotency_key`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Messages"
        ],
        "summary": "List messages",
        "description": "List your messages, inbound and outbound, newest first. Use it to catch up after your webhook endpoint was down, or to read a conversation's history.\n\nResults are paginated: `has_more` tells you whether there is another page, and you get it by passing the `id` of the last message as `starting_after`. Pages are stable while new messages arrive: nothing is skipped or repeated. See [Pagination](/concepts/pagination).\n\nFilters combine with AND. A filter value that matches nothing (including another account's `contact_id` or `chat_guid`) returns an empty page. An unknown or repeated query parameter returns `422 validation_error`.\n\nMessages stay listed after their contact is deleted or blocked.\n\nLists allow 120 requests a minute per account, across all your API keys and all lists. Over that, the answer is `429 rate_limited` with a `Retry-After` header in seconds.",
        "operationId": "v1_list_messages_v1_messages_get",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "maximum": 100,
              "minimum": 1,
              "description": "How many items to return, 1 to 100. Defaults to 50.",
              "default": 50,
              "title": "Limit"
            },
            "description": "How many items to return, 1 to 100. Defaults to 50."
          },
          {
            "name": "starting_after",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "format": "uuid"
                },
                {
                  "type": "null"
                }
              ],
              "description": "The `id` of the last item on the page you already have. Returns the items that come after it, in the same order. Leave it out for the first page. An `id` that isn't one of yours returns `422 invalid_cursor`.",
              "title": "Starting After"
            },
            "description": "The `id` of the last item on the page you already have. Returns the items that come after it, in the same order. Leave it out for the first page. An `id` that isn't one of yours returns `422 invalid_cursor`."
          },
          {
            "name": "contact_id",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "format": "uuid"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Only messages with this contact: the 1:1 conversation, plus group messages they sent or that were addressed to them as the group's primary contact. For one whole conversation, use `conversation_id`.",
              "title": "Contact Id"
            },
            "description": "Only messages with this contact: the 1:1 conversation, plus group messages they sent or that were addressed to them as the group's primary contact. For one whole conversation, use `conversation_id`."
          },
          {
            "name": "conversation_id",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "format": "uuid"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Only messages in this conversation (1:1 or group).",
              "title": "Conversation Id"
            },
            "description": "Only messages in this conversation (1:1 or group)."
          },
          {
            "name": "chat_guid",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "minLength": 1
                },
                {
                  "type": "null"
                }
              ],
              "description": "Only messages in the iMessage group with this `chat_guid`. It changes when a participant is added; the group's `conversation_id` doesn't.",
              "title": "Chat Guid"
            },
            "description": "Only messages in the iMessage group with this `chat_guid`. It changes when a participant is added; the group's `conversation_id` doesn't."
          },
          {
            "name": "direction",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "enum": [
                    "inbound",
                    "outbound"
                  ],
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Only `inbound` or only `outbound` messages.",
              "title": "Direction"
            },
            "description": "Only `inbound` or only `outbound` messages."
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "enum": [
                    "queued",
                    "pending",
                    "sent",
                    "delivered",
                    "read",
                    "failed",
                    "expired",
                    "cancelled"
                  ],
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Only messages with this `status`.",
              "title": "Status"
            },
            "description": "Only messages with this `status`."
          },
          {
            "name": "created_after",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "format": "date-time"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Only messages with `created_at` strictly after this time. ISO 8601 with a time zone, e.g. `2026-11-10T09:30:00Z`; in a query string, write `Z` or encode `+` as `%2B`.",
              "title": "Created After"
            },
            "description": "Only messages with `created_at` strictly after this time. ISO 8601 with a time zone, e.g. `2026-11-10T09:30:00Z`; in a query string, write `Z` or encode `+` as `%2B`."
          },
          {
            "name": "created_before",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "format": "date-time"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Only messages with `created_at` strictly before this time. Same format as `created_after`.",
              "title": "Created Before"
            },
            "description": "Only messages with `created_at` strictly before this time. Same format as `created_after`."
          }
        ],
        "responses": {
          "200": {
            "description": "A page of messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageList"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "422": {
            "description": "`validation_error` (a bad, unknown or repeated query parameter, or `limit` outside 1\u2013100) or `invalid_cursor` (`starting_after` is not one of your messages).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limited`: more than 120 list requests in 60 seconds from your account (all keys and all lists count together). Wait `Retry-After` seconds, then send the same request again.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/messages/{message_id}": {
      "get": {
        "tags": [
          "Messages"
        ],
        "summary": "Retrieve a message",
        "description": "Fetch a single message by its ID. Useful for polling the current delivery state when you can't subscribe to webhooks.\n\nReturns `404 not_found` for both missing messages and messages owned by a different customer \u2014 existence is not leaked across tenants.",
        "operationId": "v1_get_message_v1_messages__message_id__get",
        "parameters": [
          {
            "name": "message_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Message Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The message.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Message"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Message not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/messages/{message_id}/reactions": {
      "post": {
        "tags": [
          "Messages"
        ],
        "summary": "Add a tapback",
        "description": "Send one of the six iMessage tapbacks (`love`, `like`, `dislike`, `laugh`, `emphasize`, `question`) on a message you received. It goes out over iMessage, or RCS for an Android contact; it can't go out by SMS.\n\nThe tapback is accepted as `pending`. When it reaches the contact's phone, `message.reaction.added` fires with `direction: \"outbound\"`. If it can't be sent, `message.reaction.failed` fires with a `failed_reason`.\n\nOne tapback per message, as in iMessage: a different type replaces your previous one (`message.reaction.removed` fires for it if it was sent). Sending the type that's already on the message answers `200` with that reaction and sends nothing.\n\nCounts toward the conversation-actions rate limit (300 a minute per account).",
        "operationId": "v1_add_reaction_v1_messages__message_id__reactions_post",
        "parameters": [
          {
            "name": "message_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Message Id"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReactionCreate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "That tapback is already on the message; nothing sent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Reaction"
                }
              }
            }
          },
          "201": {
            "description": "Tapback accepted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Reaction"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Message (or reaction) not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limited`: more than 300 conversation actions in 60 seconds from your account (all keys, and tapbacks, read and typing requests, count together; list requests have their own budget). Wait `Retry-After` seconds, then send the same request again.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "422": {
            "description": "`validation_error` (bad body, unknown `type`), `invalid_target` (a message you sent), `contact_blocked`, `contact_opted_out`, `sms_not_supported` (the message came in by SMS, or the conversation is going out by SMS), `target_unsent` or `target_partly_unsent` (the contact unsent the message, or part of it).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "503": {
            "description": "`opt_out_check_unavailable`: the contact's opt-out couldn't be checked, so nothing was sent. Retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/messages/{message_id}/reactions/{reaction_id}": {
      "delete": {
        "tags": [
          "Messages"
        ],
        "summary": "Remove a tapback",
        "description": "Take back a tapback you sent. It's removed from the contact's phone, and `message.reaction.removed` fires when that happens. Only a tapback from you, on this message, can be removed.\n\nIdempotent: removing one already removed (or replaced, or failed) answers `200` with it unchanged.\n\nCounts toward the conversation-actions rate limit (300 a minute per account).",
        "operationId": "v1_remove_reaction_v1_messages__message_id__reactions__reaction_id__delete",
        "parameters": [
          {
            "name": "message_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Message Id"
            }
          },
          {
            "name": "reaction_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Reaction Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The reaction, now `removed`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Reaction"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Message (or reaction) not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limited`: more than 300 conversation actions in 60 seconds from your account (all keys, and tapbacks, read and typing requests, count together; list requests have their own budget). Wait `Retry-After` seconds, then send the same request again.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "422": {
            "description": "`target_unsent` or `target_partly_unsent`: the contact unsent the message, so the tapback can't be changed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/contacts": {
      "post": {
        "tags": [
          "Contacts"
        ],
        "summary": "Create or update a contact",
        "description": "Upsert a contact by E.164 phone number. Returns `201 Created` on a new contact, `200 OK` on an existing match. A number whose only match is a deleted contact creates a new contact.\n\nOptional fields (`first_name`, `last_name`, `email`, `city`, `state`, `timezone`) apply only when the existing record's value is null \u2014 values set previously by a rep or by a prior API call are preserved. This protects human-edited data from being clobbered by automation; to overwrite, use `PATCH /v1/contacts/{id}`.\n\n`tags` only ever adds tags. Every value is checked before anything is saved.\n\nIf `timezone` is provided, it must be one of the 20 supported IANA timezones (US, Canada, and a few international zones such as `Europe/London`); anything else returns `422 invalid_timezone`.\n\nA new contact for a number that opted out earlier (for example, one you deleted after an opt-out) starts with `opted_out: true`.",
        "operationId": "v1_upsert_contact_v1_contacts_post",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactUpsert"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Existing contact updated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contact"
                }
              }
            }
          },
          "201": {
            "description": "New contact created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contact"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "422": {
            "description": "`validation_error` (bad request shape, `phone` not E.164, an unknown field), `invalid_timezone`, `invalid_email`, `invalid_tag` or `duplicate_tag`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error`: a tag was deleted twice while it was being added. Retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Contacts"
        ],
        "summary": "List contacts",
        "description": "List your contacts, newest first. Deleted contacts are never listed.\n\nResults are paginated: `has_more` tells you whether there is another page, and you get it by passing the `id` of the last contact as `starting_after`. Pages are stable while contacts are added: nothing is skipped or repeated. See [Pagination](/concepts/pagination).\n\nFilters combine with AND. A filter value that matches nothing (including a tag you don't have) returns an empty page. An unknown or repeated query parameter returns `422 validation_error`.\n\nLists allow 120 requests a minute per account, across all your API keys and all lists. Over that, the answer is `429 rate_limited` with a `Retry-After` header in seconds.",
        "operationId": "v1_list_contacts_v1_contacts_get",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "maximum": 100,
              "minimum": 1,
              "description": "How many items to return, 1 to 100. Defaults to 50.",
              "default": 50,
              "title": "Limit"
            },
            "description": "How many items to return, 1 to 100. Defaults to 50."
          },
          {
            "name": "starting_after",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "format": "uuid"
                },
                {
                  "type": "null"
                }
              ],
              "description": "The `id` of the last item on the page you already have. Returns the items that come after it, in the same order. Leave it out for the first page. An `id` that isn't one of yours returns `422 invalid_cursor`. A contact deleted since is no longer a valid cursor.",
              "title": "Starting After"
            },
            "description": "The `id` of the last item on the page you already have. Returns the items that come after it, in the same order. Leave it out for the first page. An `id` that isn't one of yours returns `422 invalid_cursor`. A contact deleted since is no longer a valid cursor."
          },
          {
            "name": "search",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 100
                },
                {
                  "type": "null"
                }
              ],
              "description": "Only contacts matching every word, case-insensitively, as part of the first name, last name, email or phone number: `sam rivera` finds Sam Rivera. A word written like a phone number also matches by its digits, so `415-555-1234` finds `+14155551234`. The characters `\"`, `\\` and `*` are ignored. Up to 100 characters.",
              "title": "Search"
            },
            "description": "Only contacts matching every word, case-insensitively, as part of the first name, last name, email or phone number: `sam rivera` finds Sam Rivera. A word written like a phone number also matches by its digits, so `415-555-1234` finds `+14155551234`. The characters `\"`, `\\` and `*` are ignored. Up to 100 characters."
          },
          {
            "name": "opted_out",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "boolean"
                },
                {
                  "type": "null"
                }
              ],
              "description": "`true`: only opted-out contacts. `false`: only the others.",
              "title": "Opted Out"
            },
            "description": "`true`: only opted-out contacts. `false`: only the others."
          },
          {
            "name": "blocked",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "boolean"
                },
                {
                  "type": "null"
                }
              ],
              "description": "`true`: only blocked contacts. `false`: only the others.",
              "title": "Blocked"
            },
            "description": "`true`: only blocked contacts. `false`: only the others."
          },
          {
            "name": "tag",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 64
                },
                {
                  "type": "null"
                }
              ],
              "description": "Only contacts with this tag, matched trimmed and case-insensitively (`vip` matches `VIP`).",
              "title": "Tag"
            },
            "description": "Only contacts with this tag, matched trimmed and case-insensitively (`vip` matches `VIP`)."
          }
        ],
        "responses": {
          "200": {
            "description": "A page of contacts.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ContactList"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "422": {
            "description": "`validation_error` (a bad, unknown or repeated query parameter, `limit` outside 1\u2013100, or a `search` with nothing to match) or `invalid_cursor` (`starting_after` is not one of your contacts).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limited`: more than 120 list requests in 60 seconds from your account (all keys and all lists count together). Wait `Retry-After` seconds, then send the same request again.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/contacts/{contact_id}": {
      "get": {
        "tags": [
          "Contacts"
        ],
        "summary": "Retrieve a contact",
        "description": "Fetch a single contact by ID. Returns `404 not_found` for both missing contacts and contacts owned by a different customer \u2014 existence is not leaked across tenants.",
        "operationId": "v1_get_contact_v1_contacts__contact_id__get",
        "parameters": [
          {
            "name": "contact_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Contact Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The contact.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contact"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Contact not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "Contacts"
        ],
        "summary": "Update a contact",
        "description": "Overwrite the fields you send. Fields you leave out are not touched; a field sent as `null` is cleared. Unlike the upsert, this replaces values a rep set.\n\n`phone` can't be changed. Tags have their own endpoints.",
        "operationId": "v1_update_contact_v1_contacts__contact_id__patch",
        "parameters": [
          {
            "name": "contact_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Contact Id"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated contact.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contact"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Contact not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "422": {
            "description": "`validation_error` (bad request shape or an unknown field, including `phone` and `tags`), `invalid_timezone` or `invalid_email`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "Contacts"
        ],
        "summary": "Delete a contact",
        "description": "Delete a contact. It disappears from the list and from `GET /v1/contacts/{id}`, and every endpoint that takes its `id` (including `POST /v1/messages` with `contact_id`) returns `404 not_found`. Deleting it again also returns `404`.\n\nWhat stays: its messages and conversations, which `GET /v1/messages` still lists under the old `contact_id`; messages already accepted for it and still waiting, which still go out (cancel them in the dashboard to stop them); and its opt-out, which belongs to the number.\n\nUpserting the same number, sending to it with `to`, or an inbound message from it creates a **new** contact with a new `id`. Names, tags and a block are not carried over; an opt-out is.",
        "operationId": "v1_delete_contact_v1_contacts__contact_id__delete",
        "parameters": [
          {
            "name": "contact_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Contact Id"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Contact not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/contacts/{contact_id}/tags": {
      "post": {
        "tags": [
          "Contacts"
        ],
        "summary": "Add a tag to a contact",
        "description": "Add one tag. Idempotent: a tag the contact already has answers `200` unchanged. The name matches an existing tag trimmed and case-insensitively; a new name creates the tag. A newly added tag can start workflows with a \"Tag added\" trigger.",
        "operationId": "v1_add_contact_tag_v1_contacts__contact_id__tags_post",
        "parameters": [
          {
            "name": "contact_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Contact Id"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactTagAdd"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The contact, with its tags.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contact"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Contact not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "422": {
            "description": "`validation_error` (bad request shape or an unknown field), `invalid_tag` or `duplicate_tag`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error`: a tag was deleted twice while it was being added. Retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/contacts/{contact_id}/tags/{name}": {
      "delete": {
        "tags": [
          "Contacts"
        ],
        "summary": "Remove a tag from a contact",
        "description": "Remove a tag by name (URL-encode it), matched trimmed and case-insensitively. Idempotent: a tag the contact doesn't have answers `200` unchanged. Removing a tag starts no workflow.",
        "operationId": "v1_remove_contact_tag_v1_contacts__contact_id__tags__name__delete",
        "parameters": [
          {
            "name": "contact_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Contact Id"
            }
          },
          {
            "name": "name",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Name"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The contact, with its tags.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contact"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Contact not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/contacts/{contact_id}/opt-out": {
      "post": {
        "tags": [
          "Contacts"
        ],
        "summary": "Opt a contact out",
        "description": "Record that this person opted out of messages, as a STOP reply does. **This can't be undone**, through the API or the dashboard: every later send to this number answers `422 contact_opted_out`, even after the contact is deleted and created again.\n\nThe first opt-out fires a `contact.opted_out` webhook with `source: \"api\"`. New sends are refused from then on, but a message accepted earlier and still waiting (held for sending hours, or queued) can still go out; cancel it in the dashboard to stop it.\n\nIdempotent: a contact already opted out (in any way) answers `200` with its current state, and no second webhook fires.",
        "operationId": "v1_opt_out_contact_v1_contacts__contact_id__opt_out_post",
        "parameters": [
          {
            "name": "contact_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Contact Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The contact, now opted out.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contact"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Contact not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/contacts/{contact_id}/block": {
      "post": {
        "tags": [
          "Contacts"
        ],
        "summary": "Block a contact",
        "description": "A block is yours, on this contact: sends to them return `422 contact_blocked` (group conversations they're in are not affected). Their inbound messages are still stored and still fire `message.inbound`, but don't mark the conversation unread, notify anyone or start workflows. Unlike an opt-out, a block fires no webhook and can be undone with `DELETE /v1/contacts/{id}/block`. A contact deleted and created again starts unblocked.\n\nIdempotent: blocking a blocked contact answers `200` and keeps the original `blocked_at`.",
        "operationId": "v1_block_contact_v1_contacts__contact_id__block_post",
        "parameters": [
          {
            "name": "contact_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Contact Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The contact, now blocked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contact"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Contact not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "Contacts"
        ],
        "summary": "Unblock a contact",
        "description": "Undo a block: sends to this contact are accepted again. An opt-out is not affected.\n\nIdempotent: unblocking a contact that isn't blocked answers `200` unchanged.",
        "operationId": "v1_unblock_contact_v1_contacts__contact_id__block_delete",
        "parameters": [
          {
            "name": "contact_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Contact Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The contact, now unblocked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Contact"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Contact not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/conversations/{conversation_id}/read": {
      "post": {
        "tags": [
          "Conversations"
        ],
        "summary": "Mark a conversation read",
        "description": "Mark every message you've received in this conversation read. It clears the unread badge in the iBubbl inbox and, when read receipts are on for your account, sends the contact a read receipt: they see \"Read\" under their latest message on iMessage, and on RCS (Android). SMS has no read receipts.\n\nA receipt goes out only when there was something unread, and never to a group, a blocked contact or one who opted out. `read_receipt` says whether one was queued, and why not.\n\nIdempotent: calling again with nothing new answers `200` with `marked_count: 0`.\n\nCounts toward the conversation-actions rate limit (300 a minute per account).",
        "operationId": "v1_mark_conversation_read_v1_conversations__conversation_id__read_post",
        "parameters": [
          {
            "name": "conversation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Conversation Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Marked read.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ConversationRead"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Conversation not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limited`: more than 300 conversation actions in 60 seconds from your account (all keys, and tapbacks, read and typing requests, count together; list requests have their own budget). Wait `Retry-After` seconds, then send the same request again.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/conversations/{conversation_id}/typing": {
      "post": {
        "tags": [
          "Conversations"
        ],
        "summary": "Start typing",
        "description": "Show the contact the typing bubble (the three dots) in this conversation. It lasts 10 seconds: call again every 5 seconds to keep it going. Sending a message clears it on the contact's phone; so does `DELETE`, or simply not calling again.\n\nTyping shows only in a 1:1 iMessage conversation the contact has already messaged. Anywhere else the call answers `200` with `status: \"ignored\"` and an `ignored_reason`, and does nothing.\n\nTyping is a signal to the contact, so it's refused for a blocked contact or one who opted out.\n\nCounts toward the conversation-actions rate limit (300 a minute per account); calls closer than 3 seconds apart are answered without doing anything new.",
        "operationId": "v1_start_typing_v1_conversations__conversation_id__typing_post",
        "parameters": [
          {
            "name": "conversation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Conversation Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Typing started (or ignored).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TypingStatus"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Conversation not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limited`: more than 300 conversation actions in 60 seconds from your account (all keys, and tapbacks, read and typing requests, count together; list requests have their own budget). Wait `Retry-After` seconds, then send the same request again.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "422": {
            "description": "`contact_blocked` or `contact_opted_out`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "503": {
            "description": "`opt_out_check_unavailable`: the contact's opt-out couldn't be checked, so typing wasn't shown. Retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "Conversations"
        ],
        "summary": "Stop typing",
        "description": "Stop showing the typing bubble. You don't need this before sending: a sent message clears it on its own. Some phones keep showing the bubble for up to a minute after a stop that isn't followed by a message.\n\nTyping shows only in a 1:1 iMessage conversation the contact has already messaged. Anywhere else the call answers `200` with `status: \"ignored\"` and an `ignored_reason`, and does nothing.\n\nIdempotent: stopping when nothing is typing answers `200` `stopped`.\n\nCounts toward the conversation-actions rate limit (300 a minute per account).",
        "operationId": "v1_stop_typing_v1_conversations__conversation_id__typing_delete",
        "parameters": [
          {
            "name": "conversation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Conversation Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Typing stopped (or ignored).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TypingStatus"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Conversation not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "`rate_limited`: more than 300 conversation actions in 60 seconds from your account (all keys, and tapbacks, read and typing requests, count together; list requests have their own budget). Wait `Retry-After` seconds, then send the same request again.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before retrying.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/webhooks": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Create a webhook subscription",
        "description": "Register an HTTPS endpoint to receive events. The response includes the signing secret **once** \u2014 store it immediately; it cannot be retrieved again. Use the secret to verify incoming webhook signatures (see [Signature verification](/guides/webhook-signatures)).\n\nIf `event_types` is omitted, the subscription receives all event types. You can register multiple subscriptions per account (e.g., one for production, one for staging).\n\nDelivery is at-least-once with exponential backoff. Customer endpoints should be idempotent based on the event `id`.",
        "operationId": "v1_create_webhook_v1_webhooks_post",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookSubscriptionCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Subscription created. Secret returned ONCE.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSubscriptionCreated"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "422": {
            "description": "`invalid_url` (URL is not HTTPS) or `validation_error` (for example an unknown event type).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "500": {
            "description": "`internal_error`: the subscription could not be saved. Retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "List webhook subscriptions",
        "description": "Returns all currently active webhook subscriptions for the authenticated customer. Revoked subscriptions are omitted \u2014 query them by ID directly (`GET /v1/webhooks/{id}`) if you need their history.",
        "operationId": "v1_list_webhooks_v1_webhooks_get",
        "responses": {
          "200": {
            "description": "Array of active subscriptions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/WebhookSubscription"
                  },
                  "title": "Response V1 List Webhooks V1 Webhooks Get"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/webhooks/{subscription_id}": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Retrieve a webhook subscription",
        "description": "Fetch a single subscription by ID. Returns the subscription regardless of revoked status (so you can inspect history). `404 not_found` is returned for both missing subscriptions and those owned by a different customer.",
        "operationId": "v1_get_webhook_v1_webhooks__subscription_id__get",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Subscription Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The subscription.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSubscription"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Subscription not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Revoke a webhook subscription",
        "description": "Soft-revoke a subscription. After revocation, no further events are delivered to its URL. The row is retained for audit purposes; you can still fetch it with `GET /v1/webhooks/{id}` and see its `revoked_at` timestamp.\n\nRevoking an already-revoked subscription is a no-op that returns `{\"status\": \"already_revoked\"}`.",
        "operationId": "v1_revoke_webhook_v1_webhooks__subscription_id__delete",
        "parameters": [
          {
            "name": "subscription_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Subscription Id"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Revocation result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookRevokeResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "Subscription not found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "403": {
            "description": "`forbidden`: your iBubbl account is suspended or cancelled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/events/{token}": {
      "post": {
        "tags": [
          "Inbound Events"
        ],
        "summary": "Receive an event from a connected app",
        "description": "Receive a webhook from a connected third-party app (for example Recharge) and start any workflow whose **Webhook received** trigger matches it.\n\nThe URL you paste into the sending app carries its own credential, so this endpoint takes no `Authorization` header. Create a source in **Settings \u2192 Inbound events** to get the full URL; the token is shown once.\n\n**Any app (secret URL)** sources need no signature: the URL is the credential, so keep it private. They accept JSON or `application/x-www-form-urlencoded` bodies and at most 60 requests a minute. Beyond that the answer is `429` while a workflow is listening on the source; with none listening, the event is accepted and dropped.\n\n**Signed** sources must sign every request. For Recharge, that is the `X-Recharge-Hmac-Sha256` header computed with your **API Client Secret** \u2014 which is not the same value as your API token. An unsigned or mis-signed request returns `403 invalid_signature`.\n\n**Redeliveries are safe.** Providers resend events, and Recharge's dunning fires one event per charge retry. Each workflow's `event_id_path` identifies the underlying object, so repeat deliveries of the same event fold onto the run that already exists rather than messaging the customer again.\n\nA `200` means the event was accepted, not that it matched anything \u2014 check `runs_started`, and see the source's status in Settings when it is unexpectedly `0`.",
        "operationId": "receive_event_v1_events__token__post",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "title": "Token"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Event accepted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EventAck"
                }
              }
            }
          },
          "403": {
            "description": "Signature missing or does not match.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "404": {
            "description": "No such source, or it has been revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "413": {
            "description": "Body larger than 256 KB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "Secret-URL source over its limit of 60 requests a minute, while a workflow is listening on it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        },
        "security": []
      }
    }
  },
  "components": {
    "schemas": {
      "ApiError": {
        "properties": {
          "detail": {
            "allOf": [
              {
                "$ref": "#/components/schemas/ApiErrorDetail"
              }
            ],
            "description": "Error envelope. Every `/v1` error response wraps a stable `error` code and a human-readable `message` in `detail`."
          }
        },
        "type": "object",
        "required": [
          "detail"
        ],
        "title": "ApiError",
        "example": {
          "detail": {
            "error": "validation_error",
            "message": "Exactly one of contact_id, to, chat_guid, or participant_phones is required"
          }
        }
      },
      "ApiErrorDetail": {
        "properties": {
          "error": {
            "type": "string",
            "title": "Error",
            "description": "Stable machine-readable error code. Use this for branching logic in your integration; the human-readable `message` is subject to change. See the Error Reference for the catalog.",
            "examples": [
              "validation_error"
            ]
          },
          "message": {
            "type": "string",
            "title": "Message",
            "description": "Human-readable description of what went wrong.",
            "examples": [
              "Exactly one of contact_id, to, chat_guid, or participant_phones is required"
            ]
          }
        },
        "type": "object",
        "required": [
          "error",
          "message"
        ],
        "title": "ApiErrorDetail"
      },
      "Contact": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id",
            "description": "Unique contact identifier (UUID)."
          },
          "phone": {
            "type": "string",
            "title": "Phone",
            "description": "Contact's phone number as first seen. Usually E.164; a contact first created by an inbound SMS can carry the sender's bare national form (e.g. `4155551234`). Upserting any form of the same number returns this contact."
          },
          "first_name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "First Name"
          },
          "last_name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Last Name"
          },
          "email": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Email"
          },
          "city": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "City"
          },
          "state": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "State"
          },
          "timezone": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Timezone",
            "description": "IANA timezone used for this contact's sending hours, or null if not set yet. When it is null and the number is a US or Canadian one, iBubbl infers the zone from the area code and saves it here when the contact's first message is processed; otherwise your account's timezone is used. Once set, an upsert never replaces it, so pass `timezone` on the first upsert, before you send, or change it with PATCH."
          },
          "tags": {
            "items": {
              "type": "string"
            },
            "type": "array",
            "title": "Tags",
            "description": "The contact's tag names, sorted case-insensitively."
          },
          "opted_out": {
            "type": "boolean",
            "title": "Opted Out",
            "description": "True if this person has opted out: a STOP reply, a rep in the dashboard, a Do Not Disturb in your connected GoHighLevel account, or `POST /v1/contacts/{id}/opt-out`. When true, every send to this contact returns `422 contact_opted_out`. An opt-out can't be undone, and it belongs to the number: a contact deleted and created again is still opted out."
          },
          "opted_out_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Opted Out At",
            "description": "ISO 8601 UTC timestamp of the opt-out, or null."
          },
          "blocked": {
            "type": "boolean",
            "title": "Blocked",
            "description": "True if you blocked this contact (dashboard or `POST /v1/contacts/{id}/block`). Sends to them return `422 contact_blocked` until you unblock them. Unlike an opt-out, a block is yours to undo, and it fires no webhook.",
            "default": false
          },
          "blocked_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Blocked At",
            "description": "ISO 8601 UTC timestamp of the block, or null."
          },
          "created_at": {
            "type": "string",
            "title": "Created At",
            "description": "ISO 8601 UTC timestamp the contact was created."
          }
        },
        "type": "object",
        "required": [
          "id",
          "phone",
          "opted_out",
          "created_at"
        ],
        "title": "Contact",
        "example": {
          "blocked": false,
          "city": "Brooklyn",
          "created_at": "2026-05-17T14:23:45.123Z",
          "email": "sam@example.com",
          "first_name": "Sam",
          "id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f",
          "last_name": "Rivera",
          "opted_out": false,
          "phone": "+14155551234",
          "state": "NY",
          "tags": [
            "Spring promo",
            "VIP"
          ],
          "timezone": "America/Los_Angeles"
        }
      },
      "ContactList": {
        "properties": {
          "data": {
            "items": {
              "$ref": "#/components/schemas/Contact"
            },
            "type": "array",
            "title": "Data",
            "description": "Up to `limit` contacts, newest first: by `created_at`, then by `id` for contacts created in the same microsecond."
          },
          "has_more": {
            "type": "boolean",
            "title": "Has More",
            "description": "Whether more contacts match after the last one in `data`. To get them, repeat the request with `starting_after` set to that contact's `id`."
          }
        },
        "type": "object",
        "required": [
          "data",
          "has_more"
        ],
        "title": "ContactList"
      },
      "ConversationRead": {
        "properties": {
          "conversation_id": {
            "type": "string",
            "title": "Conversation Id",
            "description": "The conversation."
          },
          "marked_count": {
            "type": "integer",
            "title": "Marked Count",
            "description": "How many received messages this call marked read (0 if they already were)."
          },
          "read_receipt": {
            "type": "string",
            "enum": [
              "queued",
              "skipped"
            ],
            "title": "Read Receipt",
            "description": "`queued`: the contact will see \"Read\" (iMessage, and RCS on Android). `skipped`: no receipt goes out; see `read_receipt_skipped_reason`."
          },
          "read_receipt_skipped_reason": {
            "anyOf": [
              {
                "type": "string",
                "enum": [
                  "nothing_unread",
                  "receipts_off",
                  "group",
                  "contact_blocked",
                  "contact_opted_out",
                  "opt_out_check_unavailable"
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Read Receipt Skipped Reason",
            "description": "Why no receipt: `nothing_unread` (nothing new since the last read), `receipts_off` (read receipts are off for your account), `group` (never sent in group conversations), `contact_blocked`, `contact_opted_out`, or `opt_out_check_unavailable` (the opt-out couldn't be checked, so none was sent). Null when queued."
          }
        },
        "type": "object",
        "required": [
          "conversation_id",
          "marked_count",
          "read_receipt",
          "read_receipt_skipped_reason"
        ],
        "title": "ConversationRead",
        "example": {
          "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
          "marked_count": 2,
          "read_receipt": "queued"
        }
      },
      "EventAck": {
        "properties": {
          "received": {
            "type": "boolean",
            "title": "Received",
            "description": "Always `true` once the signature has been verified.",
            "examples": [
              true
            ]
          },
          "runs_started": {
            "type": "integer",
            "title": "Runs Started",
            "description": "How many workflow runs this event started. `0` is a normal answer \u2014 it means no active workflow's trigger matched, or this event was a redelivery that de-duplicated onto an existing run.",
            "examples": [
              1
            ]
          }
        },
        "type": "object",
        "required": [
          "received",
          "runs_started"
        ],
        "title": "EventAck",
        "example": {
          "received": true,
          "runs_started": 1
        }
      },
      "Message": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id",
            "description": "Unique message identifier (UUID)."
          },
          "direction": {
            "type": "string",
            "enum": [
              "inbound",
              "outbound"
            ],
            "title": "Direction",
            "description": "`outbound` = sent from your line (through the API, the dashboard or an automation); `inbound` = received from a contact. An inbound message has `status` `delivered`, or `read` once it has been read in the dashboard."
          },
          "created_at": {
            "type": "string",
            "title": "Created At",
            "description": "ISO 8601 UTC timestamp, with microseconds and a `Z`, of when iBubbl recorded the message. It never changes, so it is what `GET /v1/messages` sorts and filters by; pass it back as `created_after` / `created_before` unchanged.",
            "examples": [
              "2026-05-17T14:23:45.123456Z"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "pending",
              "sent",
              "delivered",
              "read",
              "failed",
              "expired",
              "cancelled"
            ],
            "title": "Status",
            "description": "Lifecycle state. `pending` = accepted and on its way out; it may be waiting for the contact's working hours or the line's hourly limit for new contacts (see `held_until`). `queued` = parked by a capacity hold (`hourly_cap_hit` / `line_offline`), retried automatically. `sent` / `delivered` / `read` = delivery progression. `failed` = the send failed \u2014 see webhook `message.failed` for the reason; it can follow `sent` (or a `read` with no confirmed delivery) when iMessage reports the send failed after accepting it (`failed_reason` `imessage_undeliverable` / `imessage_error:<code>`), and such a message can still move to `delivered` / `read` if Apple later confirms it. `expired` = still queued after an hour, so dropped; it will not send. `cancelled` = stopped in the dashboard before it was sent; it will not send (webhook `message.cancelled`)."
          },
          "to": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "To",
            "description": "The contact's phone number (E.164) for 1:1 messages: the destination of an outbound message, and the sender of an inbound one. Null for group messages \u2014 the group is identified by `chat_guid` instead.",
            "examples": [
              "+14155551234"
            ]
          },
          "body": {
            "type": "string",
            "title": "Body",
            "description": "The message text as sent."
          },
          "contact_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Contact Id",
            "description": "ID of the contact this message was sent to. For groups, this is the 'primary' participant the message was addressed to (the first participant in `participant_phones` on a new group create); the conversation's full participant list is available via the conversation."
          },
          "conversation_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Conversation Id",
            "description": "ID of the conversation this message belongs to. Stable for the lifetime of the conversation; use it to fetch the thread or correlate webhook events."
          },
          "conversation_kind": {
            "type": "string",
            "enum": [
              "one_to_one",
              "group"
            ],
            "title": "Conversation Kind",
            "description": "`one_to_one` for sends to a single contact, `group` for iMessage groups (2+ participants in addition to your iBubbl line).",
            "default": "one_to_one"
          },
          "chat_guid": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Chat Guid",
            "description": "Opaque identifier for the group chat (e.g. `any;+;<hex>`). Returned for `group` messages \u2014 use it to address later messages to the same group via the `chat_guid` request shape. Null for `one_to_one` messages, and for a brand-new group until the group has been created in iMessage (that happens when the first message goes out, which can wait for sending hours); poll `GET /v1/messages/{id}` to see it fill in. It changes when a participant is added to the group.",
            "examples": [
              "any;+;067ace6caf6c43aaa2f7d643975e15ea"
            ]
          },
          "channel": {
            "anyOf": [
              {
                "type": "string",
                "enum": [
                  "imessage",
                  "sms"
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Channel",
            "description": "How the message travelled: `imessage`, or `sms` (a text, including RCS). For an outbound message, how it actually went out (`sms` when the recipient couldn't be reached on iMessage); null until it has been sent. For an inbound message, how it arrived; null when that wasn't recorded."
          },
          "sent_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Sent At",
            "description": "ISO 8601 UTC timestamp. Set when the message is accepted, then replaced by the actual send time once it goes out. Use `status` (`sent` / `delivered` / `read`), not `sent_at`, to tell whether it has actually gone out.",
            "examples": [
              "2026-05-17T14:23:45.123Z"
            ]
          },
          "held_until": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Held Until",
            "description": "While a `pending` message is being held, the expected time (ISO 8601 UTC) it will go out: the next opening of the contact's working hours; for `hourly_limit`, an estimate of when a slot frees. Recomputed within about 10 seconds. Null when the message is not held, when `held_reason` is `working_hours` but no sending day is enabled, and when it is `hourly_limit` with no estimate.",
            "examples": [
              "2026-10-08T16:00:00+00:00"
            ]
          },
          "held_reason": {
            "anyOf": [
              {
                "type": "string",
                "enum": [
                  "working_hours",
                  "hourly_limit"
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Held Reason",
            "description": "Why a `pending` message is being held right now: `working_hours` (outside the contact's working hours) or `hourly_limit` (the line has started its limit of new contacts for this hour; the message goes out by itself when a slot frees, at about `held_until`). Null when it is not held."
          },
          "effect": {
            "anyOf": [
              {
                "type": "string",
                "enum": [
                  "slam",
                  "loud",
                  "gentle",
                  "invisible",
                  "echo",
                  "spotlight",
                  "balloons",
                  "confetti",
                  "love",
                  "lasers",
                  "fireworks",
                  "celebration",
                  "shooting_star"
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Effect",
            "description": "The iMessage send effect requested for this message, or null. It shows on the recipient's device only when `channel` is `imessage`."
          },
          "reply_to_message_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Reply To Message Id",
            "description": "The ID of the message this one quotes as an inline reply, or null. The quote shows on the recipient's device only when `channel` is `imessage`."
          },
          "attachments": {
            "items": {
              "$ref": "#/components/schemas/MessageAttachment"
            },
            "type": "array",
            "title": "Attachments",
            "description": "The files on this message, in the order they were sent: sent with `attachments`, from the dashboard or an automation, or received from the contact. Empty when there are none. File contents and URLs are not returned."
          }
        },
        "type": "object",
        "required": [
          "id",
          "direction",
          "created_at",
          "status",
          "body",
          "attachments"
        ],
        "title": "Message",
        "example": {
          "attachments": [],
          "body": "Hey Sam \u2014 quick check-in about your order.",
          "contact_id": "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f",
          "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
          "conversation_kind": "one_to_one",
          "created_at": "2026-10-08T11:40:44.770512Z",
          "direction": "outbound",
          "held_reason": "working_hours",
          "held_until": "2026-10-08T16:00:00+00:00",
          "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
          "sent_at": "2026-10-08T11:40:44.770Z",
          "status": "pending",
          "to": "+14155551234"
        }
      },
      "MessageAttachment": {
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "image",
              "gif",
              "video",
              "audio",
              "file"
            ],
            "title": "Kind",
            "description": "`file` is a PDF (or another document)."
          },
          "mime_type": {
            "type": "string",
            "title": "Mime Type",
            "examples": [
              "image/jpeg"
            ]
          },
          "size_bytes": {
            "type": "integer",
            "title": "Size Bytes",
            "description": "Size in bytes. An inbound file can show `0` for a few seconds, until iBubbl has received it."
          }
        },
        "type": "object",
        "required": [
          "kind",
          "mime_type",
          "size_bytes"
        ],
        "title": "MessageAttachment"
      },
      "MessageList": {
        "properties": {
          "data": {
            "items": {
              "$ref": "#/components/schemas/Message"
            },
            "type": "array",
            "title": "Data",
            "description": "Up to `limit` messages, newest first: by `created_at`, then by `id` for messages recorded in the same microsecond."
          },
          "has_more": {
            "type": "boolean",
            "title": "Has More",
            "description": "Whether more messages match after the last one in `data`. To get them, repeat the request with `starting_after` set to that message's `id`."
          }
        },
        "type": "object",
        "required": [
          "data",
          "has_more"
        ],
        "title": "MessageList"
      },
      "Reaction": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id",
            "description": "Reaction ID. The `reaction_id` in `message.reaction.*` events."
          },
          "message_id": {
            "type": "string",
            "title": "Message Id",
            "description": "The message the tapback is on."
          },
          "conversation_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Conversation Id",
            "description": "The conversation that message is in."
          },
          "type": {
            "type": "string",
            "title": "Type",
            "description": "`love`, `like`, `dislike`, `laugh`, `emphasize` or `question`."
          },
          "emoji": {
            "type": "string",
            "title": "Emoji",
            "description": "The tapback as an emoji, e.g. `\u2764\ufe0f`."
          },
          "direction": {
            "type": "string",
            "enum": [
              "outbound"
            ],
            "const": "outbound",
            "title": "Direction",
            "description": "Always `outbound`: a tapback you sent."
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "sent",
              "failed",
              "removed"
            ],
            "title": "Status",
            "description": "`pending`: accepted, not yet on the contact's phone. `sent`: delivered to Messages (`message.reaction.added` fires then). `failed`: never sent, see `failed_reason` (`message.reaction.failed` fires). `removed`: you removed it, or a newer tapback of yours on the same message replaced it."
          },
          "failed_reason": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Failed Reason",
            "description": "Why a `failed` tapback wasn't sent: `sms_target`, `target_not_found`, `bb_rejected`, `messages_error`, `expired`, `retract_rejected` or `target_unsent`. Null otherwise."
          },
          "created_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Created At",
            "description": "When you sent the tapback (UTC)."
          },
          "removed_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Removed At",
            "description": "When it was removed or replaced (UTC); null while active."
          }
        },
        "type": "object",
        "required": [
          "id",
          "message_id",
          "conversation_id",
          "type",
          "emoji",
          "direction",
          "status",
          "failed_reason",
          "created_at",
          "removed_at"
        ],
        "title": "Reaction",
        "example": {
          "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
          "created_at": "2026-11-11T15:04:05.123Z",
          "direction": "outbound",
          "emoji": "\u2764\ufe0f",
          "id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
          "message_id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
          "status": "pending",
          "type": "love"
        }
      },
      "TypingStatus": {
        "properties": {
          "conversation_id": {
            "type": "string",
            "title": "Conversation Id",
            "description": "The conversation."
          },
          "typing": {
            "type": "boolean",
            "title": "Typing",
            "description": "Whether the typing bubble is on after this call."
          },
          "status": {
            "type": "string",
            "enum": [
              "started",
              "stopped",
              "ignored"
            ],
            "title": "Status",
            "description": "`started` (or kept going), `stopped`, or `ignored`: typing can't be shown here and nothing was done; see `ignored_reason`."
          },
          "ignored_reason": {
            "anyOf": [
              {
                "type": "string",
                "enum": [
                  "group",
                  "not_imessage",
                  "no_chat"
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Ignored Reason",
            "description": "`group` (not available in group conversations), `not_imessage` (the contact is on SMS or RCS), `no_chat` (the contact hasn't messaged this line yet). Null otherwise."
          },
          "expires_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Expires At",
            "description": "When the bubble stops unless you call again (UTC), 10 seconds after the call. Null unless `started`."
          }
        },
        "type": "object",
        "required": [
          "conversation_id",
          "typing",
          "status",
          "ignored_reason",
          "expires_at"
        ],
        "title": "TypingStatus",
        "example": {
          "conversation_id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
          "expires_at": "2026-11-11T15:04:15.123Z",
          "status": "started",
          "typing": true
        }
      },
      "ContactUpdate": {
        "properties": {
          "first_name": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 128
              },
              {
                "type": "null"
              }
            ],
            "title": "First Name",
            "examples": [
              "Sam"
            ]
          },
          "last_name": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 128
              },
              {
                "type": "null"
              }
            ],
            "title": "Last Name",
            "examples": [
              "Rivera"
            ]
          },
          "email": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 254
              },
              {
                "type": "null"
              }
            ],
            "title": "Email",
            "description": "Must look like an email address, or `422 invalid_email`.",
            "examples": [
              "sam@example.com"
            ]
          },
          "city": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 128
              },
              {
                "type": "null"
              }
            ],
            "title": "City",
            "description": "Trimmed; blank clears it.",
            "examples": [
              "Brooklyn"
            ]
          },
          "state": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 128
              },
              {
                "type": "null"
              }
            ],
            "title": "State",
            "description": "Trimmed, a 2-letter code upper-cased; blank clears it.",
            "examples": [
              "NY"
            ]
          },
          "timezone": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Timezone",
            "description": "One of the supported IANA zones, or `422 invalid_timezone`. `null` returns the contact to automatic: iBubbl infers the zone from a US or Canadian area code again.",
            "examples": [
              "America/New_York"
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "title": "ContactUpdate",
        "description": "Only the fields you send change. Send a field as `null` to clear it. `phone` can't be changed, and tags have their own endpoints. Fields not listed here are rejected with `422 validation_error`.",
        "example": {
          "email": "sam.rivera@example.com"
        }
      },
      "ContactUpsert": {
        "properties": {
          "phone": {
            "type": "string",
            "title": "Phone",
            "description": "Phone number in E.164 format (e.g. `+14155551234`). The unique key for contacts \u2014 upserting an existing phone returns the same contact ID.",
            "examples": [
              "+14155551234"
            ]
          },
          "first_name": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 128
              },
              {
                "type": "null"
              }
            ],
            "title": "First Name",
            "description": "Contact's first name. Only applied if the existing record's `first_name` is null \u2014 rep-edited values are sticky and won't be overwritten by automation. To change it, use `PATCH /v1/contacts/{id}`.",
            "examples": [
              "Sam"
            ]
          },
          "last_name": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 128
              },
              {
                "type": "null"
              }
            ],
            "title": "Last Name",
            "description": "Contact's last name. Same sticky-update semantics as `first_name`.",
            "examples": [
              "Rivera"
            ]
          },
          "email": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 254
              },
              {
                "type": "null"
              }
            ],
            "title": "Email",
            "description": "Email address. Must look like one (`name@domain.tld`, no spaces), or the call returns `422 invalid_email`. Same sticky-update semantics as `first_name`.",
            "examples": [
              "sam@example.com"
            ]
          },
          "city": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 128
              },
              {
                "type": "null"
              }
            ],
            "title": "City",
            "description": "City. Trimmed; blank is treated as not sent. Same sticky-update semantics as `first_name`.",
            "examples": [
              "Brooklyn"
            ]
          },
          "state": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 128
              },
              {
                "type": "null"
              }
            ],
            "title": "State",
            "description": "State, province or region. Trimmed, and a 2-letter code is upper-cased (`ny` \u2192 `NY`). Same sticky-update semantics as `first_name`.",
            "examples": [
              "NY"
            ]
          },
          "timezone": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Timezone",
            "description": "IANA timezone for working-hours scheduling. Must be one of the supported allowlist (US/Canada timezones, plus common international). Sends to this contact respect the timezone for outside-hours scheduling. Same sticky-update semantics as names.",
            "examples": [
              "America/Los_Angeles"
            ]
          },
          "tags": {
            "anyOf": [
              {
                "items": {
                  "type": "string"
                },
                "type": "array",
                "maxItems": 20
              },
              {
                "type": "null"
              }
            ],
            "title": "Tags",
            "description": "Tag names to add, at most 20. Only ever adds: tags the contact already has are kept, and none are removed. A name matches an existing tag trimmed and case-insensitively (`vip` reuses `VIP`); a new name creates the tag. Each name is 1\u201364 characters with no invisible characters, or the call returns `422 invalid_tag` before anything is saved. A newly added tag can start workflows with a \"Tag added\" trigger.",
            "examples": [
              [
                "VIP",
                "Spring promo"
              ]
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "phone"
        ],
        "title": "ContactUpsert",
        "description": "Fields not listed here are rejected with `422 validation_error`.",
        "example": {
          "city": "Brooklyn",
          "email": "sam@example.com",
          "first_name": "Sam",
          "last_name": "Rivera",
          "phone": "+14155551234",
          "state": "NY",
          "tags": [
            "VIP",
            "Spring promo"
          ],
          "timezone": "America/New_York"
        }
      },
      "MessageCreate": {
        "properties": {
          "contact_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Contact Id",
            "description": "1:1 send by existing contact ID (a UUID). Mutually exclusive with `to`, `chat_guid`, and `participant_phones`.",
            "examples": [
              "c8a1f4e2-3b9d-4f7c-a8e5-1d2c3b4a5e6f"
            ]
          },
          "to": {
            "anyOf": [
              {
                "type": "string",
                "pattern": "^\\+[1-9]\\d{7,14}$"
              },
              {
                "type": "null"
              }
            ],
            "title": "To",
            "description": "1:1 send by E.164 phone number (e.g. `+14155551234`). If the number doesn't match an existing contact, one is created automatically. Mutually exclusive with `contact_id`, `chat_guid`, and `participant_phones`.",
            "examples": [
              "+14155551234"
            ]
          },
          "chat_guid": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Chat Guid",
            "description": "Send into an existing iMessage group identified by its opaque `chat_guid` (e.g. `any;+;<hex>`). Webhook events don't carry it: read it from the response to a send into the group, or from `GET /v1/messages/{id}` for any message in the group (an inbound `message_id` works too). The value changes when a participant is added to the group; an old value then returns `404 group_not_found`. Mutually exclusive with `contact_id`, `to`, and `participant_phones`.",
            "examples": [
              "any;+;067ace6caf6c43aaa2f7d643975e15ea"
            ]
          },
          "participant_phones": {
            "anyOf": [
              {
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              {
                "type": "null"
              }
            ],
            "title": "Participant Phones",
            "description": "Create a brand-new iMessage group and send the first message in one call. List of 2 or more E.164 phone numbers (your iBubbl line is added automatically; do not include it). Mutually exclusive with `contact_id`, `to`, and `chat_guid`. Optionally pair with `group_name` to set the group's display name.",
            "examples": [
              [
                "+14155551234",
                "+14155557654"
              ]
            ]
          },
          "group_name": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 80
              },
              {
                "type": "null"
              }
            ],
            "title": "Group Name",
            "description": "Display name for a new iMessage group. Only valid alongside `participant_phones`. Optional \u2014 iMessage will fall back to the participants' names if omitted.",
            "examples": [
              "Team Alpha"
            ]
          },
          "body": {
            "anyOf": [
              {
                "type": "string",
                "minLength": 1
              },
              {
                "type": "null"
              }
            ],
            "title": "Body",
            "description": "The message text. Plain text only; emoji + Unicode are supported. For a line break, include a newline (`\\n` in the JSON string); use `\\n`, not `\\r\\n`. A retry with the same `idempotency_key` must send the identical body, line breaks included, or it returns `409 idempotency_conflict`. Required unless `attachments` is set; with attachments it is sent as their caption, and it is required for the first message to a contact (`422 first_message_needs_text`).",
            "examples": [
              "Hey Sam \u2014 quick check-in about your order."
            ]
          },
          "attachments": {
            "anyOf": [
              {
                "items": {
                  "type": "string",
                  "maxLength": 2083,
                  "minLength": 1,
                  "format": "uri"
                },
                "type": "array",
                "maxItems": 10,
                "minItems": 1
              },
              {
                "type": "null"
              }
            ],
            "title": "Attachments",
            "description": "Up to 10 files to send, each an `https` URL iBubbl downloads when the request arrives. Images (JPEG, PNG, WebP, HEIC, HEIF) and GIFs up to 25 MiB each, MP4/MOV video up to 50 MiB, PDF up to 10 MB, audio (MP3, M4A, AAC) up to 25 MiB on its own; 86 MB in all. With other files, audio must be one MP3 of at most 2 MB. The type comes from the response's `Content-Type` (or the URL's extension when that is `application/octet-stream`). A URL that can't be downloaded, or a file that breaks these rules, rejects the whole request (`422 attachment_fetch_failed`, `attachment_too_large`, `unsupported_attachment`, `attachment_combination_not_supported`) and nothing is sent. Only with `to` or `contact_id`, and not with `effect` or `reply_to_message_id`. The URLs, in order, are part of the payload compared for idempotency; a replay never downloads them again.",
            "examples": [
              [
                "https://example.com/photos/order-1042.jpg"
              ]
            ]
          },
          "reply_to_message_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Reply To Message Id",
            "description": "Send as an inline reply that quotes an earlier message (a UUID: the `id` of a message, or the `message_id` of a `message.inbound` event). The message must be in the conversation you are sending to: the same contact for a 1:1 send, the same group for a `chat_guid` send; otherwise `422 reply_target_not_found`. A message the contact unsent returns `422 reply_target_unsent`, and one they unsent part of `422 reply_target_partly_unsent`. iMessage only: when the message goes out as SMS (see `channel`) it is sent as plain text, without the quote. Not allowed with `participant_phones` or `attachments`.",
            "examples": [
              "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b"
            ]
          },
          "effect": {
            "anyOf": [
              {
                "type": "string",
                "enum": [
                  "slam",
                  "loud",
                  "gentle",
                  "invisible",
                  "echo",
                  "spotlight",
                  "balloons",
                  "confetti",
                  "love",
                  "lasers",
                  "fireworks",
                  "celebration",
                  "shooting_star"
                ]
              },
              {
                "type": "null"
              }
            ],
            "title": "Effect",
            "description": "An iMessage send effect. Bubble effects: `slam`, `loud`, `gentle`, `invisible` (Invisible Ink). Screen effects: `echo`, `spotlight`, `balloons`, `confetti`, `love`, `lasers`, `fireworks`, `celebration`, `shooting_star`. iMessage only: when the message goes out as SMS (see `channel`) it is sent without the effect. Not allowed with `participant_phones` or `attachments`.",
            "examples": [
              "confetti"
            ]
          },
          "idempotency_key": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1,
            "title": "Idempotency Key",
            "description": "Client-supplied unique key per logical send. Keys never expire: reusing a key with the same payload replays the original message, however long ago it was sent, and the same key with a different payload returns `409 idempotency_conflict`. The one exception is a message that ended `failed` or `expired`: its key is released and the retry is sent as a new message. The payload compared is the routing field exactly as sent (`to`, `contact_id`, `chat_guid`, or the sorted `participant_phones` plus `group_name`), `body`, and `effect`, `reply_to_message_id` and `attachments` when set, so a retry that switches from `to` to the same contact's `contact_id` is a 409. Generate a new UUIDv4 for every distinct message.",
            "examples": [
              "a3f2c8d9-e0f1-4b4d-9c5a-7b8e2d1f0c4a"
            ]
          }
        },
        "type": "object",
        "required": [
          "idempotency_key"
        ],
        "title": "MessageCreate",
        "description": "Fields not listed here are ignored without an error: the request still succeeds and they are not stored.",
        "example": {
          "body": "Hey Sam \u2014 quick check-in about your order.",
          "idempotency_key": "a3f2c8d9-e0f1-4b4d-9c5a-7b8e2d1f0c4a",
          "to": "+14155551234"
        }
      },
      "ReactionCreate": {
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "love",
              "like",
              "dislike",
              "laugh",
              "emphasize",
              "question"
            ],
            "title": "Type",
            "description": "One of the six iMessage tapbacks: `love`, `like`, `dislike`, `laugh`, `emphasize`, `question`. Custom emoji tapbacks can't be sent. Fields not listed here are rejected with `422 validation_error`.",
            "examples": [
              "love"
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "type"
        ],
        "title": "ReactionCreate",
        "example": {
          "type": "love"
        }
      },
      "ContactTagAdd": {
        "properties": {
          "name": {
            "type": "string",
            "title": "Name",
            "description": "Tag name, 1\u201364 characters with no invisible characters. Matches an existing tag trimmed and case-insensitively; a new name creates the tag.",
            "examples": [
              "VIP"
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "required": [
          "name"
        ],
        "title": "ContactTagAdd",
        "example": {
          "name": "VIP"
        }
      },
      "WebhookSubscriptionCreate": {
        "properties": {
          "url": {
            "type": "string",
            "title": "Url",
            "description": "HTTPS URL that will receive webhook POST requests. HTTP is rejected (`422 invalid_url`).",
            "examples": [
              "https://api.example.com/ibubbl-webhooks"
            ]
          },
          "event_types": {
            "anyOf": [
              {
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              {
                "type": "null"
              }
            ],
            "title": "Event Types",
            "description": "Filter to a specific subset of events. Omit, or pass `null` or `[]`, to receive ALL event types, including ones added later. Every entry must be a type from the [Webhook Events catalog](/webhooks/events); an unknown type returns `422 validation_error`.",
            "examples": [
              [
                "message.sent",
                "message.failed",
                "message.inbound"
              ]
            ]
          }
        },
        "type": "object",
        "required": [
          "url"
        ],
        "title": "WebhookSubscriptionCreate",
        "example": {
          "event_types": [
            "message.sent",
            "message.failed",
            "message.inbound"
          ],
          "url": "https://api.example.com/ibubbl-webhooks"
        }
      },
      "WebhookRevokeResponse": {
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "revoked",
              "already_revoked"
            ],
            "title": "Status",
            "description": "`revoked` if this call performed the revocation; `already_revoked` if the subscription was already revoked."
          }
        },
        "type": "object",
        "required": [
          "status"
        ],
        "title": "WebhookRevokeResponse",
        "example": {
          "status": "revoked"
        }
      },
      "WebhookSubscription": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id",
            "description": "Unique subscription identifier (UUID)."
          },
          "url": {
            "type": "string",
            "title": "Url",
            "description": "The HTTPS URL receiving events."
          },
          "secret_prefix": {
            "type": "string",
            "title": "Secret Prefix",
            "description": "First 14 characters of the signing secret (`whsec_` + 8 hex). Useful for identifying which subscription's secret is in use without revealing the full value. The full secret is returned only once, at creation time.",
            "examples": [
              "whsec_a3f2c8d9"
            ]
          },
          "event_types": {
            "anyOf": [
              {
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              {
                "type": "null"
              }
            ],
            "title": "Event Types",
            "description": "Event filter, or null if subscribed to all events."
          },
          "created_at": {
            "type": "string",
            "title": "Created At",
            "description": "ISO 8601 UTC timestamp."
          },
          "last_delivered_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Last Delivered At",
            "description": "ISO 8601 UTC timestamp of the most recent successful delivery to this subscription, or null if no event has been delivered yet."
          },
          "revoked_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Revoked At",
            "description": "ISO 8601 UTC timestamp the subscription was revoked, or null if active. Revoked subscriptions stop receiving events."
          }
        },
        "type": "object",
        "required": [
          "id",
          "url",
          "secret_prefix",
          "created_at"
        ],
        "title": "WebhookSubscription",
        "example": {
          "created_at": "2026-05-17T14:23:45.123Z",
          "event_types": [
            "message.sent",
            "message.failed"
          ],
          "id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d",
          "last_delivered_at": "2026-05-17T15:01:12.456Z",
          "secret_prefix": "whsec_a3f2c8d9",
          "url": "https://api.example.com/ibubbl-webhooks"
        }
      },
      "WebhookSubscriptionCreated": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id",
            "description": "Unique subscription identifier (UUID)."
          },
          "url": {
            "type": "string",
            "title": "Url",
            "description": "The HTTPS URL receiving events."
          },
          "secret_prefix": {
            "type": "string",
            "title": "Secret Prefix",
            "description": "First 14 characters of the signing secret (`whsec_` + 8 hex). Useful for identifying which subscription's secret is in use without revealing the full value. The full secret is returned only once, at creation time.",
            "examples": [
              "whsec_a3f2c8d9"
            ]
          },
          "event_types": {
            "anyOf": [
              {
                "items": {
                  "type": "string"
                },
                "type": "array"
              },
              {
                "type": "null"
              }
            ],
            "title": "Event Types",
            "description": "Event filter, or null if subscribed to all events."
          },
          "created_at": {
            "type": "string",
            "title": "Created At",
            "description": "ISO 8601 UTC timestamp."
          },
          "last_delivered_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Last Delivered At",
            "description": "ISO 8601 UTC timestamp of the most recent successful delivery to this subscription, or null if no event has been delivered yet."
          },
          "revoked_at": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Revoked At",
            "description": "ISO 8601 UTC timestamp the subscription was revoked, or null if active. Revoked subscriptions stop receiving events."
          },
          "secret": {
            "type": "string",
            "title": "Secret",
            "description": "Full signing secret. **Save it now** \u2014 this is the only time it is returned. Use it to verify the `X-iBubbl-Signature` header on incoming webhook POSTs (HMAC-SHA256 over `{t}.{raw request body}`, keyed with this whole string). See [Signature verification](/guides/webhook-signatures).",
            "examples": [
              "whsec_a3f2c8d9e0f1b4d52c8a1f4e23b9d4f7ca8e51d2c3b4a5e6f7a8b9c0d1e2f3a4"
            ]
          },
          "warning": {
            "type": "string",
            "title": "Warning",
            "description": "Reminder that the secret cannot be retrieved again. Present only on the create response."
          }
        },
        "type": "object",
        "required": [
          "id",
          "url",
          "secret_prefix",
          "created_at",
          "secret",
          "warning"
        ],
        "title": "WebhookSubscriptionCreated",
        "example": {
          "created_at": "2026-05-17T14:23:45.123Z",
          "event_types": [
            "message.sent",
            "message.failed"
          ],
          "id": "4a5b6c7d-8e9f-0a1b-2c3d-4e5f6a7b8c9d",
          "secret": "whsec_a3f2c8d9e0f1b4d52c8a1f4e23b9d4f7ca8e51d2c3b4a5e6f7a8b9c0d1e2f3a4",
          "secret_prefix": "whsec_a3f2c8d9",
          "url": "https://api.example.com/ibubbl-webhooks",
          "warning": "Save this secret now \u2014 it cannot be retrieved again."
        }
      }
    },
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "ib_pk_<64 hex>",
        "description": "Bearer API key. Format: `Authorization: Bearer ib_pk_<64 hex>` (70 characters). Generate keys from Dashboard \u2192 Settings \u2192 API Access."
      }
    }
  },
  "servers": [
    {
      "url": "https://api.ibubbl.com"
    }
  ],
  "tags": [
    {
      "name": "Messages"
    },
    {
      "name": "Contacts"
    },
    {
      "name": "Conversations"
    },
    {
      "name": "Webhooks"
    },
    {
      "name": "Inbound Events"
    }
  ],
  "security": [
    {
      "BearerAuth": []
    }
  ]
}
