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

# Cancel a message

> Stop a message you sent that hasn't gone out yet: one waiting for the contact's working hours or the line's hourly limit (`status: "pending"` with a `held_reason`), or queued by a capacity hold (`status: "queued"`). It moves to `cancelled`, will never be sent, and `message.cancelled` fires.

A message that is already being sent can't be stopped (`409 message_dispatching`); it goes out, and `message.sent` fires. Retrying the original `POST /v1/messages` with the same `idempotency_key` returns the cancelled message and doesn't send it again.

Idempotent: cancelling a cancelled message answers `200` with it, and no second webhook fires.

Counts toward the conversation-actions rate limit (300 a minute per account).

### Cancel a message

**Method:** `POST` | **Endpoint:** `/v1/messages/{message_id}/cancel`

**Description:** Stop a message you sent before it goes out: one waiting for the contact's [sending hours](https://docs.ibubbl.com/concepts/working-hours.md) or the line's hourly limit for new contacts (`status: "pending"` with a `held_reason`), or one queued by a capacity hold (`status: "queued"`). It will never be sent. See [Edit, Undo Send and Cancel](https://docs.ibubbl.com/guides/editing-and-unsending.md#cancel-a-message-before-it-goes-out).

**Authentication:** API key in the `Authorization` header: `Authorization: Bearer ib_pk_<64 hex>`. Create keys in the dashboard under **Settings → API access**.

**Rate limit:** 300 conversation actions per 60 seconds per account (edits, unsends, cancels, tapbacks, mark read and typing together, across all your API keys). Over that, you get `429 rate_limited` with a `Retry-After` header; see [Rate limits](https://docs.ibubbl.com/concepts/limits.md#rate-limits).

#### Request Body

None. The path parameter `message_id` is the `id` from [Send a message](https://docs.ibubbl.com/api-reference/messages/send.md), or from [List messages](https://docs.ibubbl.com/api-reference/messages/list.md), where messages sent from the dashboard and by automations appear too.

#### How it behaves

* **It's immediate.** The answer is `200` with `status: "cancelled"`, and [`message.cancelled`](https://docs.ibubbl.com/webhooks/events#message-cancelled) fires. The message stays in the conversation history as cancelled; the contact never sees it.
* **It's safe to call twice.** Cancelling a cancelled message answers `200` with it, and no second `message.cancelled` fires.
* **Only while it's waiting.** A message already `sent`, `delivered`, `read`, `failed` or `expired`, or one you received, gets `422 not_cancellable`. To take back a message that already went out, [unsend it](https://docs.ibubbl.com/api-reference/messages/unsend.md) within 110 seconds.
* **Not once your line has picked it up.** In the second or so before a waiting message goes out, your line takes it to send; from then on, cancelling gets `409 message_dispatching`, and the message is sent (`message.sent` fires).
* **Retrying the send doesn't bring it back.** `POST /v1/messages` again with the same `idempotency_key` returns this cancelled message and sends nothing. To send it after all, use a new key.
* **Any channel.** It works whether the message would have gone out by iMessage or SMS, one-to-one or to a group.

```bash cURL theme={null}
curl -X POST https://api.ibubbl.com/v1/messages/8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b/cancel \
  -H "Authorization: Bearer $IBUBBL_API_KEY"
```

```typescript TypeScript theme={null}
const messageId = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b";

const res = await fetch(
  `https://api.ibubbl.com/v1/messages/${encodeURIComponent(messageId)}/cancel`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.IBUBBL_API_KEY}` },
    signal: AbortSignal.timeout(10_000),
  },
);

const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.detail?.error}`);
console.log(data.status); // "cancelled"
```

```python Python theme={null}
import os
import urllib.parse

import requests

API_KEY = os.environ["IBUBBL_API_KEY"]

message_id = "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b"

res = requests.post(
    f"https://api.ibubbl.com/v1/messages/{urllib.parse.quote(message_id, safe='')}/cancel",
    headers={"Authorization": f"Bearer {API_KEY}"},
    timeout=10,
)
data = res.json()
if not res.ok:
    raise RuntimeError(f"{res.status_code} {data['detail']['error']}")
print(data["status"])  # "cancelled"
```

```json 200 OK theme={null}
{
  "id": "8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b",
  "direction": "outbound",
  "created_at": "2026-11-05T22:30:00.418221Z",
  "status": "cancelled",
  "to": "+14155551234",
  "body": "Hey Sam — 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",
  "chat_guid": null,
  "channel": null,
  "sent_at": "2026-11-05T22:30:00.418Z",
  "held_until": null,
  "held_reason": null,
  "effect": null,
  "reply_to_message_id": null,
  "attachments": [],
  "edited_at": null,
  "edit_count": 0,
  "edit_status": null,
  "unsend_status": null,
  "unsent_at": null
}
```

```json 409 Conflict theme={null}
{
  "detail": {
    "error": "message_dispatching",
    "message": "This message is already being sent and can't be changed."
  }
}
```

#### Responses

**200 OK**

The [message](https://docs.ibubbl.com/api-reference/messages/retrieve.md), now `cancelled` (or already cancelled). `held_until` and `held_reason` are `null`, and `channel` stays `null`: it never went out.

**401 Unauthorized**

```json theme={null}
{
  "detail": {
    "error": "unauthorized",
    "message": "Invalid or revoked API key"
  }
}
```

Other `message` values: `Authorization Bearer token required`, `API key required (use Authorization: Bearer ib_pk_…)`, `Invalid API key format`, `Customer account not found`.

**403 Forbidden**

```json theme={null}
{
  "detail": {
    "error": "forbidden",
    "message": "Account is suspended"
  }
}
```

Your iBubbl account is suspended or cancelled (`message` is then `Account is cancelled`). Every API-key endpoint returns this.

**404 Not Found**

```json theme={null}
{
  "detail": {
    "error": "not_found",
    "message": "Message not found"
  }
}
```

Also returned for a malformed ID and a message that belongs to another account.

**409 Conflict**

`message_dispatching`: your line already has the message and will send it. Nothing changed; `message.sent` follows.

**422 Unprocessable Entity**

```json theme={null}
{
  "detail": {
    "error": "not_cancellable",
    "message": "Only a message of yours that is still waiting to go out can be cancelled."
  }
}
```

`not_cancellable`: the message isn't one of yours that is still waiting to go out. It was sent, failed or expired, it's a message you received, or it's a meeting invite. Nothing changed.

**429 Too Many Requests**

```json theme={null}
{
  "detail": {
    "error": "rate_limited",
    "message": "Too many conversation actions; wait 12s (limit 300 a minute per account)."
  }
}
```

Wait the `Retry-After` header's seconds, then send the same request again.


## OpenAPI

````yaml POST /v1/messages/{message_id}/cancel
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.


    Authenticate every request with a Bearer API key (`ib_pk_<hex>`) — generate
    one from your dashboard's API Access settings. See the
    [Authentication](/authentication) guide.
servers:
  - url: https://api.ibubbl.com
security:
  - BearerAuth: []
tags:
  - name: Messages
  - name: Contacts
  - name: Conversations
  - name: Webhooks
  - name: Inbound Events
paths:
  /v1/messages/{message_id}/cancel:
    post:
      tags:
        - Messages
      summary: Cancel a message
      description: >-
        Stop a message you sent that hasn't gone out yet: one waiting for the
        contact's working hours or the line's hourly limit (`status: "pending"`
        with a `held_reason`), or queued by a capacity hold (`status:
        "queued"`). It moves to `cancelled`, will never be sent, and
        `message.cancelled` fires.


        A message that is already being sent can't be stopped (`409
        message_dispatching`); it goes out, and `message.sent` fires. Retrying
        the original `POST /v1/messages` with the same `idempotency_key` returns
        the cancelled message and doesn't send it again.


        Idempotent: cancelling a cancelled message answers `200` with it, and no
        second webhook fires.


        Counts toward the conversation-actions rate limit (300 a minute per
        account).
      operationId: v1_cancel_message_v1_messages__message_id__cancel_post
      parameters:
        - name: message_id
          in: path
          required: true
          schema:
            type: string
            title: Message Id
      responses:
        '200':
          description: The message, now `cancelled`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Message'
        '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'
        '404':
          description: Message not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        '409':
          description: >-
            `message_dispatching`: it is already being sent and can't be
            stopped.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        '422':
          description: >-
            `not_cancellable`: not a message of yours that is still waiting to
            go out (it was sent, failed or expired, or it is a message you
            received).
          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, typing, edit,
            unsend and cancel 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'
components:
  schemas:
    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 — 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 — 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 — 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.
        edited_at:
          anyOf:
            - type: string
            - type: 'null'
          title: Edited At
          description: >-
            ISO 8601 UTC timestamp of the latest edit that reached the
            recipient: one of yours (`POST /v1/messages/{id}/edit`, or the
            dashboard), or, on an inbound message, the contact's own edit. Null
            when it was never edited.
          examples:
            - '2026-05-17T14:25:00.000000Z'
        edit_count:
          type: integer
          maximum: 5
          minimum: 0
          title: Edit Count
          description: >-
            How many of your edits reached the recipient. iMessage allows five
            per message. Always `0` on an inbound message.
          default: 0
        edit_status:
          anyOf:
            - type: string
              enum:
                - pending
                - edited
                - failed
            - type: 'null'
          title: Edit Status
          description: >-
            `pending` = your latest edit is on its way (`body` already shows the
            new text); `failed` = it didn't reach the recipient, and `body` is
            back to the text they have (webhook `message.edit_failed`); `edited`
            = the message was edited. Null when it never was.
        unsend_status:
          anyOf:
            - type: string
              enum:
                - pending
                - unsent
                - partly_unsent
                - failed
            - type: 'null'
          title: Unsend Status
          description: >-
            `pending` = your Undo Send is on its way; `unsent` = the whole
            message was unsent (by you, or by the contact on an inbound message)
            and `body` is empty; `partly_unsent` = part of it was (its text, a
            link preview or a file); `failed` = your Undo Send didn't happen and
            the recipient still has the message (webhook
            `message.unsend_failed`). Null when nothing was unsent.
        unsent_at:
          anyOf:
            - type: string
            - type: 'null'
          title: Unsent At
          description: >-
            ISO 8601 UTC timestamp of when the message (or the latest part of
            it) was unsent. Null unless `unsend_status` is `unsent` or
            `partly_unsent`.
          examples:
            - '2026-05-17T14:24:10.000000Z'
      type: object
      required:
        - id
        - direction
        - created_at
        - status
        - body
        - attachments
      title: Message
      example:
        attachments: []
        body: Hey Sam — 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'
    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
    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
    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
  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 → Settings → API Access.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.