openapi: 3.1.0
info:
  title: Hooka API
  version: "2026-06-01"
  summary: Send and manage TikTok DM outreach from your own TikTok accounts.
  description: |
    Hooka sends TikTok direct messages from TikTok accounts you connect to your
    workspace ("senders"), with pacing, daily caps, deduplication, follow-ups and
    reply detection handled for you. This document describes every route of the
    public API v1 as it behaves today.

    - **Base URL:** `https://api.hooka.to/v1`
    - **Guides:** [app.hooka.to/docs.html](https://app.hooka.to/docs.html)
    - **Remote MCP server** (for AI agents): `https://mcp.hooka.to/mcp`
    - **Platform:** only `tiktok` is available. On `POST /senders`,
      `POST /campaigns` and the `GET /senders` filter, any other `platform` value
      returns 400 `platform_not_supported`. Other routes ignore a `platform` query
      parameter.

    # Authentication

    Send `Authorization: Bearer <token>` on every request. There is no
    query-string or cookie authentication.

    | Token | What it can do |
    |---|---|
    | `hk_live_…` | Everything. Writes act on your real TikTok accounts. |
    | `hk_test_…` | **Read-only.** Any method other than GET/HEAD returns 403 `test_key_read_only`. There is **no sandbox or test mode**: reads return your real data and nothing is simulated. |
    | `mcp_at_…` | OAuth access token issued to the remote MCP server. Acts as a live key, except that it cannot list, create or revoke API keys (403 `oauth_token_not_allowed`). |

    A credential belongs to one workspace. A resource of another workspace behaves
    exactly like one that does not exist (404, or 400 `invalid_cursor` when used
    as a cursor), never 403.

    # Requests

    - Bodies are **JSON only**. Send `Content-Type: application/json` with every
      body. A non-empty body with another Content-Type, or with none, returns 415
      `unsupported_content_type`. That is what `curl -d` sends when you forget `-H`.
    - An empty body, or JSON `null`, counts as `{}`. `POST /senders`, `start`,
      `pause`, `resume`, `connection/refresh` and `webhook_endpoints/{id}/test`
      work with no body at all.
    - Maximum body size: 1 MiB (413 `body_too_large`). Invalid JSON: 400 `invalid_json`.
    - Unknown body fields and unknown query parameters are **ignored silently**
      (the one exception is `PATCH /senders/{id}`, which rejects any key other
      than `label`). For example, `GET /campaigns?status=running` is not filtered.
    - Paths are case-sensitive and a trailing slash does not match. An unknown
      path or method returns 404 `route_not_found`; the API never answers 405.
    - Every GET route also answers HEAD.

    # Errors

    Every response carries a `Hooka-Request-Id` header. Errors share one envelope:

    ```json
    {
      "error": {
        "type": "invalid_request_error",
        "code": "invalid_request",
        "message": "limit must be an integer between 1 and 100.",
        "param": "limit",
        "request_id": "req_9eab538a53ad4f74840f1ee1927578d3"
      }
    }
    ```

    - `param` is present only when one field or query parameter is at fault, as a
      dotted path (`settings.sender_ids`, `targets.3`, `follow_ups.0.wait_days`).
    - `retry_after` (seconds) is added on 429 responses and on 409
      `exit_ip_not_ready`; 400 `country_required` adds `suggested_country`.
    - `request_id` equals the `Hooka-Request-Id` header. Quote it to support.
    - Messages are English and may be reworded. Branch on `code`, and on `type` for
      a code you do not recognise.

    | `type` | HTTP status |
    |---|---|
    | `invalid_request_error` | 400 (also 413 `body_too_large` and 415 `unsupported_content_type`) |
    | `authentication_error` | 401 |
    | `permission_error` | 403 |
    | `not_found_error` | 404 |
    | `conflict_error` | 409 |
    | `rate_limit_error` | 429 |
    | `api_error` | 500 (or the 5xx returned) |

    While the API restarts, the proxy in front of it can answer 502 or 504 without
    this envelope. Treat any non-JSON 5xx as retryable.

    # Idempotency

    Every `POST` except `POST /api_keys` accepts an optional `Idempotency-Key`
    header. GET, PATCH and DELETE ignore it.

    - The key is 1–255 characters after trimming (otherwise 400
      `invalid_idempotency_key`), scoped to your workspace and kept for 24 hours
      from its first use.
    - Same key and same request (method, route, path ids exactly as sent, JSON body
      with key order ignored) → the stored response is replayed with the header
      `Idempotent-Replayed: true`. Nothing runs again.
    - Same key, different request → 409 `idempotency_key_reuse`. Use a new key for
      a corrected body.
    - Same key while the first request is still running → 409
      `idempotency_key_in_use`. Retry later with the **same** key (a request stuck
      for more than 5 minutes is taken over).
    - **Not stored**, so a retry with the same key runs again: every 5xx, every 429,
      and the state errors that tell you to fix something and retry (each
      operation lists them).
    - A replay is a snapshot of the original response, not current state. GET the
      resource if you need current state.
    - `cmp_<uuid>` and the bare `<uuid>` in a path are different requests for
      idempotency purposes. Use one form consistently.

    Send a key on every POST you might retry. Without one, a retried
    `POST /conversations/{id}/messages` sends a second real DM, and a retried
    `POST /campaigns`, `POST /senders` or `POST /webhook_endpoints` creates a
    second resource.

    # Pagination

    Paginated lists return:

    ```json
    { "object": "list", "data": [], "has_more": false, "next_cursor": null }
    ```

    - `limit`: an integer from 1 to 100 written in digits, default 50. Anything else
      (`0`, `101`, `10.5`, `abc`, empty, repeated) is a 400 with `param: limit`;
      values are never clamped.
    - `starting_after`: the id of an item of the **same list**. Pass `next_cursor`,
      which is the id of the last item when `has_more` is true and `null` otherwise.
      A foreign, deleted or malformed id is a 400 `invalid_cursor`. An empty or
      repeated `starting_after` is ignored and returns the first page.
    - `GET /webhook_endpoints` and `GET /api_keys` are not paginated (no `has_more`
      or `next_cursor`, unspecified order). `GET /events` has two modes, described
      on that operation.

    # Rate limit

    300 requests per fixed one-minute window **per workspace**, shared by all its
    API keys, OAuth tokens and MCP traffic. Counted responses carry
    `x-ratelimit-limit`, `x-ratelimit-remaining` and `x-ratelimit-reset` (seconds
    until the window resets). Over the limit you get 429 `rate_limited` with a
    `Retry-After` header and `error.retry_after`. 401s, `test_key_read_only` 403s
    and `route_not_found` 404s are not counted.

    # IDs and timestamps

    - Every id is a type prefix plus a lowercase UUID: `ws_` workspace, `snd_`
      sender, `cmp_` campaign, `tgt_` target, `sup_` suppression, `con_`
      conversation, `msg_` message, `rpl_` queued reply, `we_` webhook endpoint,
      `evt_` event, `key_` API key. On input a bare UUID is accepted too. A path id
      that is not a UUID returns that resource's 404.
    - Every timestamp is an integer Unix time in seconds, UTC (`created`,
      `sent_at`, `at`, `replied_at`, `last_used`). Message times (`at`) are
      TikTok's own times when Hooka can read them, and estimates otherwise.

    # Limits to know before you build

    - **No sandbox.** Every write with a live key or OAuth token acts on real TikTok
      accounts and sends real DMs.
    - **"sent" is not a delivery receipt.** It means TikTok accepted the message and
      showed no refusal notice within about 8 seconds. See *Campaigns*.
    - **No subscription, no sending.** Without an active subscription a campaign can
      be started but nothing is sent, and no error is returned.
    - **Dashboard-only settings.** The workspace's default country, each sender's
      daily limit and warm-up can only be changed in the Hooka dashboard. A
      sender's country can only be changed by Hooka support.
    - **Automatic pauses are silent.** A running campaign whose senders are all
      disconnected is paused with no event, and is not resumed automatically,
      unless the last of them stopped because TikTok ended its session
      (`session_expired`): then it resumes by itself once one of its senders
      connects again. A
      campaign set in the dashboard to follow before each DM, and to pause at
      TikTok's following limit, is also paused with no event when one of its
      senders reaches that limit; Hooka resumes that one by itself once its
      senders are below the limit again. A campaign paused because its senders
      were paused (`cooldown`) also resumes by itself once one of them connects
      again. The API does not say why a campaign is paused.
    - **Events are best effort.** An internal error can lose an event. Reconcile
      with `GET /events`.
  contact:
    name: Hooka support
    email: hola@hooka.to
    url: https://app.hooka.to/docs.html
externalDocs:
  description: Guides (quickstart, webhooks, reference)
  url: https://app.hooka.to/docs.html
servers:
  - url: https://api.hooka.to/v1
    description: Production. It is the only environment; there is no sandbox.
security:
  - bearerAuth: []

tags:
  - name: Workspace
    description: |
      Who the credential belongs to, and what you need to connect a sender: the
      default country for new senders (`exit_ip`) and a free seat (`seats`).
  - name: API keys
    description: |
      Create, list and revoke API keys of your workspace. The secret is returned
      once, at creation. OAuth tokens (`mcp_at_`) cannot use these routes.
  - name: Senders
    description: |
      A sender is one TikTok account of your workspace. You connect it with a QR
      code: Hooka opens TikTok's login page in a browser that goes out through
      the sender's **own dedicated residential IP, in the account's country**
      (`country`), and you show its QR to the account owner. No other sender and
      no other Hooka customer uses that IP. If no IP is available in that
      country, Hooka assigns one from the nearest country (`exit_ip.fallback`).

      **Before connecting**, call `GET /workspace`: `seats.limit` must be `null`
      or greater than `seats.used`. Pass the account's `country` on
      `POST /senders` unless the workspace's default (`exit_ip.region`) is right.
      A sender's country cannot be changed later.

      **Connect flow**
      1. `POST /senders` → 201 with `status: awaiting_scan` and
         `connection.status: pending`. There is no QR yet. While
         `exit_ip.status` is `provisioning`, Hooka is preparing the sender's IP
         and the QR comes once it is ready (usually under a minute).
      2. Poll `GET /senders/{id}` every 3–5 s, or listen for
         `sender.connection.qr_generated`. When `connection.status` is
         `requires_scan`, show `connection.qr_code` (a PNG data URI). Always show
         the latest one: while nobody scans, TikTok's QR is replaced about every
         2 minutes.
      3. The owner scans it with the TikTok app (`scanned`) and confirms the login
         on the phone.
      4. If TikTok asks "Verify it's you", `connection.status` becomes
         `verification_required`: answer with
         `POST /senders/{id}/connection/verify`.
      5. Success: the sender's `status` becomes `connected` (`sender.connected`).
         Failure: `connection.status` is `expired` or `failed`, or the sender
         becomes `disconnected` (`sender.connection_failed`); the reason is in
         `status_reason`. Start a new attempt with
         `POST /senders/{id}/connection/refresh`.
      6. After `exit_ip_unavailable` (the IP was not ready in time), Hooka keeps
         preparing the IP: poll `GET /senders/{id}` until `exit_ip.status` is
         `ready`, then refresh. No event fires at that moment.

      An attempt lasts about 300 s from the moment TikTok's QR first appears, and
      scanning does not extend it. A verification extends it, up to 15 minutes in
      total.

      **One attempt at a time per workspace.** Creating or refreshing a sender
      cancels every other connection attempt in progress in the workspace
      (`superseded`), including one started from the dashboard.

      | `status` | Meaning | Holds a seat | Sends DMs |
      |---|---|---|---|
      | `awaiting_scan` | A connection attempt is in progress. `connection` is set. | yes | no |
      | `connected` | Logged in. | yes | yes |
      | `disconnected` | Not logged in; see `status_reason`. | no | no |
      | `cooldown` | Paused by Hooka because TikTok flagged the account (`account_flagged`), the sender could not act several times in a row (`actions_blocked`), or the workspace has more connected senders than seats (`plan_limit`, see "Over the plan's seats" below). Replies keep syncing. Reconnect with `POST …/connection/refresh` (with `plan_limit`, only once a seat is free). | yes (no with plan_limit) | no |
      | `banned` | Reserved. No current code path produces it. | yes | no |

      Hooka checks that the sender is still logged in to TikTok when its worker
      starts and while it runs (on every send and inbox sync). When TikTok's own
      login prompt is still there after a reload, the sender becomes
      `disconnected` with `status_reason: "session_expired"`.

      **Sender paused (`cooldown`).** When TikTok shows the account its
      account-status warning (the account may be at risk; contact support or
      change the password), or the sender could not act on 4 targets in a row
      (chat not opened, text not typed, follow not sticking; counted by
      attempts, not by time) or found no Message button on 6 profiles in a row,
      Hooka stops sending from it: `sender.status_changed` fires, campaigns left
      with no connected sender are paused, and the target being sent plus the
      targets of that streak that failed before anything was typed go back to
      `pending`. What TikTok says about a recipient never counts, and a send
      that goes out resets the count. Reconnecting it (refresh and scan) fires
      `sender.connected` and resumes the campaigns paused for this. When the
      same `actions_blocked` failure hits several senders of different
      customers at once, Hooka treats it as a general incident: the owner is
      told there is nothing to do, and Hooka support resumes them.

      **Over the plan's seats (`plan_limit`).** Hooka never lets more senders send than the workspace's seats. If there are more `connected` senders than `seats.limit` (a trial of extra seats ended, or the plan's seats went down), Hooka pauses the most recently created ones: they become `cooldown` with `status_reason: "plan_limit"` and fire `sender.status_changed`. Nothing is deleted: they get no new targets, their follow-ups wait, campaigns left with no connected sender are paused, and replies from people keep syncing (you can't reply from them meanwhile: `409 sender_not_connected`). Such a sender doesn't hold a seat. It resumes by itself, firing `sender.connected` and resuming the campaigns paused for this (and, as a reconnection would, the ones paused because another of their senders lost its session or was paused for `account_flagged` or `actions_blocked`), as soon as there is room: when the owner adds seats in the Hooka dashboard (Billing → Manage subscription), or a few minutes after another sender is disconnected (`DELETE /v1/senders/{id}` or from the dashboard). It doesn't take the seat of a sender that dropped on its own (for example `session_expired`): reconnect that one, or disconnect it to make room (if you connect or refresh a different sender in its place, that one takes the seat). Senders `awaiting_scan` and senders paused for `account_flagged` or `actions_blocked` keep their seats. When a trial of extra seats ends, the oldest senders keep the seats, counting also those that dropped on their own during the trial (for example `session_expired`) or are paused for `account_flagged` or `actions_blocked`: the most recent `connected` ones are paused, and a more recent sender already paused for `account_flagged` or `actions_blocked` that doesn't fit moves to `plan_limit` (`sender.status_changed` with `previous_status: "cooldown"`; if TikTok still flags it when it resumes, it is paused again before anything is sent). `POST …/connection/refresh` on a paused sender works only if there is a free seat (otherwise `403 sender_limit_reached`). If it is still paused 7 days later, Hooka disconnects it (`disconnected`, `status_reason: "plan_limit"`) and releases its IP; refresh it once a seat is free. If TikTok ends the session of a paused sender, it becomes `disconnected` with `session_expired` (see "Detecting a lost session"): it no longer resumes by itself, so refresh it once a seat is free; its IP is still released 7 days after the pause.
  - name: Campaigns
    description: |
      A campaign sends one initial DM (step 0) to each target, then up to 10
      follow-ups, from the senders in its `settings.sender_ids`.

      **Lifecycle.** `draft` → `running` ⇄ `paused` → `completed`. `archived` is
      set only from the dashboard.

      | Action | Allowed from | Needs a connected sender of the campaign |
      |---|---|---|
      | `start` | `draft`, `paused`, or `completed` with at least one `pending` target | yes |
      | `pause` | `running` | no |
      | `resume` | `paused`, or `completed` with at least one `pending` target | yes |

      - A running or paused campaign becomes `completed` as soon as it has no
        `pending` and no `processing` targets (drafts never complete). **`completed` is not final**: when a follow-up
        becomes due, or targets are added, it goes back to `running` (with a
        connected sender) and `campaign.started` is emitted again.
      - Hooka pauses a running campaign **automatically and without any event**
        when none of its senders is connected any more. It is not resumed when a
        sender reconnects: call `resume`. Two exceptions resume by themselves: a
        pause because TikTok ended the session of its last connected sender
        (`session_expired`), once one of its senders connects again, and a
        pause because its senders were paused (`cooldown`), once one of them
        connects again.
        There is no `campaign.paused` event;
        poll `GET /campaigns/{id}` to detect a pause.
      - A campaign set in the dashboard to follow before each DM and to pause at
        TikTok's following limit is also paused automatically, with no event,
        when one of its senders reaches that limit. Hooka resumes it by itself
        once its senders are below the limit again.
      - The API does not expose why a campaign is `paused` (by you, by the
        dashboard, or by one of these automatic pauses).
      - Without an active subscription a running campaign sends nothing, and no
        error is returned.

      **Pacing and daily caps**
      - `settings.rate_limit_seconds` (30–3600, default 60) is the minimum gap
        between two DMs **of the same sender**, measured from when the previous
        attempt's result was recorded. For a campaign that uses a dashboard send
        window, the gap is derived instead from the window length and
        `daily_limit`, counted as 100 when it is `null` (for example 12 hours and
        90 DMs gives one every 8 minutes), and `rate_limit_seconds` is not used.
        The gap is never below 30 s, or 60 s when the dashboard's follow-before-DM
        option is on.
      - `settings.daily_limit` (1–500, or `null` for none) caps DMs per day for this
        campaign, **all its senders combined**.
      - Each sender also has its own daily cap across all its campaigns: 30 by
        default, changeable only in the dashboard (up to a platform maximum, 100 by
        default). `settings.daily_limit` cannot raise it.
      - Both caps count every recorded attempt, including failed, blocked and
        rate-limited ones; attempts that failed before anything was typed (for
        example `recipient_unreachable`) and first DMs skipped with
        `already_messaged` do not count, unless the sender followed the person
        on that visit (the dashboard-only "follow before DM" setting follows
        before the chat is read). Targets skipped with `profile_not_found`
        don't count either. The day starts at midnight UTC.
      - Targets are sent in the order they were added, with due follow-ups first. A
        sender shared by several campaigns serves the oldest-added targets first:
        there is no round-robin between campaigns.

      **Follow-ups.** A follow-up is sent `wait_days` after the previous step was
      sent, only by the sender that sent that previous step, and only if the
      recipient has not replied to any earlier DM of the campaign. Stop-on-reply is
      always on. While that sender is disconnected, the follow-up waits (and keeps
      the campaign from completing); if the sender is deleted with
      `DELETE /senders/{id}` or removed from the campaign in the dashboard, it is
      skipped with `sender_unavailable`. A failed or skipped step ends that
      person's sequence.

      **Templates.** `{{key}}` placeholders (ASCII letters, digits and `_`,
      case-insensitive, spaces inside the braces allowed) are filled from the
      target's `variables`. Built-ins: `{{username}}` or `{{handle}}` (the
      recipient's handle) and `{{my_handle}}` or `{{tu_handle}}` (the sending
      account's handle). A target variable with the same name overrides a
      built-in. A missing or blank variable renders empty
      (`on_missing_variable: blank`, the default) or skips the target (`skip`). A
      message that renders empty is always skipped (`empty_message`). For each
      target and step, one variant is picked at random.

      **What `sent` means.** Hooka's browser pastes the text into TikTok's chat and
      presses Enter. `sent` means the composer emptied (TikTok accepted the text)
      and no "not delivered" notice appeared within about 8 seconds (Hooka watches the
      thread, the message itself and TikTok's pop-up notices, dialogs and tooltips).
      It is **not** a delivery or read receipt: the message can land in the
      recipient's message requests, and a notice shown later, or in a UI language
      Hooka does not recognise, is not detected. When the send cannot be confirmed,
      the target fails with `send_unconfirmed` and is **never retried**, to avoid a
      duplicate DM.

      Campaigns created or edited in the dashboard are visible here, but
      dashboard-only settings (send window, per-account dedup, follow before DM)
      are not shown in `settings` even though they apply. `settings.sender_ids`
      always reflects the campaign's current senders: they can be changed in the
      dashboard, not through the API. The internal campaigns behind comment-to-DM
      automations are not visible to the API at all (404).
  - name: Targets
    description: |
      One target is one message to one person: step 0 is the initial DM and step N
      is follow-up N, each with its own `tgt_` id.

      | `status` | Meaning |
      |---|---|
      | `pending` | Waiting to be sent. Also used again after a `rate_limited` attempt, which is retried automatically with no limit. |
      | `processing` | A sender claimed it and the send is in flight. |
      | `sent` | See *What `sent` means* under Campaigns. |
      | `failed` | Terminal. The reason is in `error` / `error_message`. One exception: when Hooka pauses a sender (`cooldown`), the targets of that sender that failed in the streak before anything was typed go back to `pending`. |
      | `skipped` | Terminal. Nothing was sent. The reason is in `error`. |

      A target left `processing` for more than 15 minutes fails with
      `send_unconfirmed` and is not retried. A late result from the worker can
      still correct it, so the same target can emit `target.failed` and then
      `target.sent`.

      **Adding targets.** Handles are normalized (trimmed, every leading `@`
      removed, lowercased) and must then be 1–40 ASCII letters, digits, `_`, `.`
      or `-`. Items are skipped, with no target created and no event, when the
      handle is `invalid`, already in this campaign in **any** status
      (`duplicate_in_campaign`: a failed or skipped person can never be re-added),
      `suppressed`, or already sent a DM by another campaign of the workspace,
      including a send that could not be confirmed (`duplicate_across_campaigns`,
      only when `dedupe_across_campaigns` is true). Suppression and
      cross-campaign dedup are checked again right before each send. Right
      before typing a first DM (step 0), the sender's chat with the person is
      read too: if it already holds a message from that sender that went out
      and the person has never written there, nothing is typed and the target
      is skipped with `already_messaged` (TikTok doesn't deliver a second
      message to someone who hasn't replied and doesn't follow the sender). A
      target whose TikTok account doesn't exist (deleted, renamed or a wrong
      username; TikTok says so for that
      username with the sender's session, after a reload and without a
      session) is skipped with `profile_not_found` before anything is followed
      or typed, at any step. A
      person
      only waiting in another campaign is accepted. While another campaign is
      sending to them this one waits, and if that DM goes out (or can't be
      confirmed) this one skips them (`duplicate_across_campaigns`). They get
      one DM only if the other campaign also has `dedupe_across_campaigns`.
  - name: Suppressions
    description: |
      The workspace Do-Not-Contact list, shared with the dashboard. A suppressed
      handle is never messaged:
      - Adding a handle immediately skips every `pending` target with that handle in
        all your campaigns (`target.skipped`, reason `suppressed`), before the
        response is returned. Targets already `processing` are not touched.
      - Adding the handle later as a target is skipped
        (`skipped[].reason: suppressed`), and every send re-checks the list.

      An inbound message containing the whole word `stop`, `unsubscribe`, `baja` or
      `desuscribir` (any case) adds that person's handle with reason `opt_out`,
      when the conversation's handle is known. Any message containing the word
      counts, so "don't stop!" also opts out.

      Removing a suppression does not restore targets that were already skipped.
  - name: Conversations
    description: |
      Every TikTok DM thread Hooka synced for any sender of the workspace: campaign
      replies, comment-to-DM threads, and chats started by hand in TikTok.

      - `status` is set in the dashboard and is read-only through the API. Reading
        through the API never marks a conversation as read.
      - Message `at` values are the times TikTok shows (to the second or the
        minute) when Hooka can read them, and the exact send time for messages
        sent through Hooka. Otherwise Hooka estimates them when it reads the thread
        and corrects them later if TikTok shows a time. The order of the stored
        messages is preserved, but a message Hooka reads later is placed by its
        time and can land before messages you already listed (for example, one
        the person wrote just before your reply went out).
      - New inbound messages are announced with the `message.received` event. Use
        it (or list the thread again) to catch every new message: a
        `starting_after` cursor only returns what comes after it in the thread.
  - name: Webhook endpoints
    description: |
      Hooka POSTs each event to every **enabled** endpoint subscribed to its type
      (or to `"*"`). The body is the event object, identical to
      `GET /events/{id}`.

      **Request headers:** `Content-Type: application/json`,
      `Hooka-Signature: t=<unix seconds>,v1=<64 hex>`, `Hooka-Event-Id: evt_…`,
      `Hooka-Event-Type: <type>` and `User-Agent: Hooka-Webhooks/1.0`.

      **Verify the signature.**
      `v1 = hex(HMAC_SHA256(key, t + "." + raw_body))`, where the key is the
      **full** secret including its `whsec_` prefix. Compute it over the raw
      request bytes (re-serialized JSON does not match), compare in constant time,
      and reject when `|now − t|` exceeds your tolerance, for example 300 s. `t` is
      the time of this delivery attempt, so it is fresh on every retry. Hooka
      publishes no source IP list; rely on the signature.

      **Delivery rules**
      - Answer any 2xx within 10 seconds. Everything else is a failure: 3xx
        (redirects are not followed), 4xx, 5xx, timeouts and network or TLS errors.
      - Failures are retried after 1 min, 5 min, 30 min, 2 h, 6 h, 24 h and 24 h:
        at most 8 attempts over about 56.6 hours, then the delivery is dropped.
        There is no manual redelivery; recover missed events with `GET /events`.
      - Delivery is at-least-once and unordered. Deduplicate on the event `id`
        (equal to `Hooka-Event-Id`).
      - Endpoints are never disabled automatically. Disabling one cancels its
        pending deliveries, and events emitted while it is disabled or before it
        existed are never sent to it.
      - Each event carries a snapshot of the object taken when it was emitted.
      - Delivery logs (last 20 per endpoint) are visible only in the dashboard.
  - name: Events
    description: |
      Every event is stored whether or not you have webhook endpoints, and kept for
      30 days (the newest event of each type is kept even longer). Poll this list
      instead of, or in addition to, webhooks.

      Emission is best effort: an internal error can lose an event.
  - name: Webhook events
    description: |
      The payloads Hooka POSTs to your webhook endpoints. Each body is an event
      object whose `data.object` depends on `type`. Payloads are snapshots taken
      when the event was emitted. Key order inside `data` is not stable: never rely
      on it, and verify the signature over the raw bytes.

      Nothing emits an event for: a campaign being created, paused, resumed from
      `paused` or archived; targets being added (except that adding targets to a
      `completed` campaign that has a connected sender restarts it and emits
      `campaign.started`); a suppression being added or removed (the targets it
      skips do emit `target.skipped`); conversation status changes; replies you
      send through the API; sender label changes.

x-tagGroups:
  - name: Account
    tags: [Workspace, API keys]
  - name: Senders
    tags: [Senders]
  - name: Outreach
    tags: [Campaigns, Targets, Suppressions]
  - name: Inbox
    tags: [Conversations]
  - name: Events and webhooks
    tags: [Webhook endpoints, Events, Webhook events]

paths:
  # ───────────────────────────── Workspace ─────────────────────────────
  /workspace:
    get:
      tags: [Workspace]
      operationId: getWorkspace
      summary: Retrieve the workspace
      description: |
        Returns the workspace of the credential, with what `POST /senders` needs:

        - A free seat: `seats.limit` is `null` (unlimited) or greater than
          `seats.used`. `seats.limit: 0` means no active subscription.
        - A country for the sender: `exit_ip.region` is the workspace's
          confirmed default country, used when `POST /senders` has no
          `country`. `no_region` does not block: pass `country`.
          `unavailable`: Hooka has no IP provider active right now.

        Each sender has its own dedicated IP (`exit_ip` on the sender).

        `subscription` says whether Hooka works for the workspace: with
        `unpaid` (last invoice unpaid) or `ended` (subscription ended) nothing that
        acts on TikTok works and those calls return 403 `no_active_subscription`;
        with `ended`, `disconnects_at` says when its senders are disconnected and
        their IPs released.

        Works with test keys and OAuth tokens.
      responses:
        '200':
          description: The workspace.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Workspace' }
              example:
                id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
                object: workspace
                name: Default
                created: 1788000000
                exit_ip: { status: ready, region: ES, suggested_region: null, per_sender: true }
                seats: { limit: 3, used: 1, trial: null }
                subscription: { status: active, ends_at: null, disconnects_at: null }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/workspace \
              -H "Authorization: Bearer $HOOKA_API_KEY"

  # ───────────────────────────── API keys ─────────────────────────────
  /api_keys:
    post:
      tags: [API keys]
      operationId: createApiKey
      summary: Create an API key
      description: |
        Creates a new key. The `secret` is returned **only in this response**.
        `mode: test` creates a read-only key (there is no sandbox).

        This route does **not** support `Idempotency-Key`: storing its response
        would store the secret, so every call creates a new key.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ApiKeyCreate' }
            example: { mode: test, label: read-only reporting }
      responses:
        '201':
          description: The new key, with its secret.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyCreated' }
              example:
                id: key_6a1e3b5c-7d9f-4b2a-8c4e-6f8a0b2c4d6e
                object: api_key
                mode: test
                prefix: hk_test_1a2b3c
                secret: hk_test_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f
        '400':
          description: '`invalid_request` (for example an unknown `mode`, or a `label` over 80 characters), or `invalid_json`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_mode:
                  summary: Unknown mode
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: "Invalid enum value. Expected 'live' | 'test', received 'sandbox'"
                      param: mode
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                invalid_json: { $ref: '#/components/examples/ErrInvalidJson' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: '`test_key_read_only`, or `oauth_token_not_allowed`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                test_key_read_only: { $ref: '#/components/examples/ErrTestKeyReadOnly' }
                oauth_token_not_allowed: { $ref: '#/components/examples/ErrOauthTokenNotAllowed' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/api_keys \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"mode":"test","label":"read-only reporting"}'
    get:
      tags: [API keys]
      operationId: listApiKeys
      summary: List API keys
      description: |
        Returns every key of the workspace, **revoked ones included**. Not
        paginated, in no particular order. Secrets are never returned.
        `last_used` is approximate (updated at most once a minute).
      responses:
        '200':
          description: All keys.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyList' }
              example:
                object: list
                data:
                  - id: key_6a1e3b5c-7d9f-4b2a-8c4e-6f8a0b2c4d6e
                    object: api_key
                    mode: live
                    prefix: hk_live_8a3d3f
                    label: backend
                    revoked: false
                    last_used: 1790781240
                    created: 1788000000
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: '`oauth_token_not_allowed`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                oauth_token_not_allowed: { $ref: '#/components/examples/ErrOauthTokenNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/api_keys \
              -H "Authorization: Bearer $HOOKA_API_KEY"
  /api_keys/{id}:
    parameters:
      - $ref: '#/components/parameters/ApiKeyId'
    delete:
      tags: [API keys]
      operationId: revokeApiKey
      summary: Revoke an API key
      description: |
        Revokes the key. It stops working on its next request (401) and stays in
        the list with `revoked: true`. Revoking an already revoked key succeeds
        again. A key can revoke itself.
      responses:
        '200':
          description: Revoked.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyRevoked' }
              example: { id: key_6a1e3b5c-7d9f-4b2a-8c4e-6f8a0b2c4d6e, object: api_key, revoked: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: '`test_key_read_only`, or `oauth_token_not_allowed`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                test_key_read_only: { $ref: '#/components/examples/ErrTestKeyReadOnly' }
                oauth_token_not_allowed: { $ref: '#/components/examples/ErrOauthTokenNotAllowed' }
        '404':
          description: '`not_found`: no such key in this workspace.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: { type: not_found_error, code: not_found, message: No such key., request_id: req_9eab538a53ad4f74840f1ee1927578d3 }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl -X DELETE https://api.hooka.to/v1/api_keys/key_6a1e3b5c-7d9f-4b2a-8c4e-6f8a0b2c4d6e \
              -H "Authorization: Bearer $HOOKA_API_KEY"

  # ───────────────────────────── Senders ─────────────────────────────
  /senders:
    post:
      tags: [Senders]
      operationId: createSender
      summary: Create a sender (start a QR connection)
      description: |
        Creates a sender and starts its first QR login attempt. The body is
        optional.

        **Country and IP.** `country` is the TikTok account's country (ISO
        3166-1 alpha-2; `UK` is stored as `GB`). The sender gets its own
        dedicated residential IP there, and the country cannot be changed later.
        Without `country`, the workspace's confirmed default country is used
        (`exit_ip.region` in `GET /workspace`); without one, 400
        `country_required` with `suggested_country`. `exit_ip.status` is `ready`
        when the sender already has its IP, or `provisioning` while Hooka
        prepares it (usually under a minute; a few minutes when it buys one).
        If no IP is available in that country, Hooka assigns one from the
        nearest country (`exit_ip.fallback: true`).

        The QR is produced asynchronously by a browser that starts once the IP is
        ready, so the response always shows `connection.status: pending` and
        `qr_code: null`. Poll `GET /senders/{id}` every 3–5 s, or listen for the
        `sender.connection.qr_generated` webhook.

        **Checks, in order:** a valid body (400 `invalid_request`,
        `platform_not_supported`), an active subscription (403
        `no_active_subscription`), a country (400 `country_required`), a free seat
        (403 `sender_limit_reached`; attempts this call cancels do not count) and
        an IP available in that country or its nearest ones (409
        `exit_ip_not_ready`, with `retry_after`). A failed check changes nothing.

        **Side effect:** on success, every other connection attempt in progress in
        the workspace is cancelled. They end as `disconnected` with
        `status_reason: superseded` and a `sender.connection_failed` event.
        Connect one account at a time.

        **Idempotency:** supported. Not stored, so a retry with the same key runs
        again: `no_active_subscription`, `country_required`,
        `sender_limit_reached`, `exit_ip_not_ready`, 429 and 5xx. A replay returns
        the original body, still `pending`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SenderCreate' }
            example: { label: 'Sales ES #1', country: ES }
      responses:
        '201':
          description: The sender, `awaiting_scan`, with `connection.status` `pending`.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sender' }
              example:
                id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                object: sender
                platform: tiktok
                username: null
                label: 'Sales ES #1'
                country: ES
                status: awaiting_scan
                status_reason: null
                health: null
                daily_limit: 30
                sent_today: 0
                connection:
                  status: pending
                  qr_code: null
                  verification: null
                  websocket_url: wss://api.hooka.to/ws/sessions/0b6f7d7e-3f7e-4a0e-8a51-1f2e3d4c5b6a
                  error: null
                exit_ip: { status: provisioning, country: null, fallback: false, dedicated: true }
                created: 1790760000
        '400':
          description: '`invalid_request` (bad `label` or `country`), `country_required`, `platform_not_supported`, `invalid_json` or `invalid_idempotency_key`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_label:
                  summary: Label too long
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: label must be a string of up to 80 characters, or null to clear it.
                      param: label
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                bad_country: { $ref: '#/components/examples/ErrInvalidCountry' }
                country_required: { $ref: '#/components/examples/ErrCountryRequired' }
                platform_not_supported: { $ref: '#/components/examples/ErrPlatformNotSupported' }
                invalid_idempotency_key: { $ref: '#/components/examples/ErrInvalidIdempotencyKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: '`no_active_subscription`, `sender_limit_reached` or `test_key_read_only`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                no_active_subscription: { $ref: '#/components/examples/ErrNoActiveSubscription' }
                sender_limit_reached: { $ref: '#/components/examples/ErrSenderLimitReached' }
                test_key_read_only: { $ref: '#/components/examples/ErrTestKeyReadOnly' }
        '409':
          description: |
            `exit_ip_not_ready`: no dedicated IP is available right now in that
            country or its nearest countries (checked less than 15 minutes ago;
            `retry_after` and `Retry-After` say when to retry), or Hooka has no IP
            provider active (`retry_after: null`). Connecting without an IP would tie
            the account to a datacenter IP, so there is no override. Also
            `idempotency_key_reuse` and `idempotency_key_in_use`.
          headers:
            Retry-After: { $ref: '#/components/headers/RetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                cooling_down: { $ref: '#/components/examples/ErrExitIpCoolingDown' }
                unavailable: { $ref: '#/components/examples/ErrExitIpUnavailable' }
                idempotency_key_reuse: { $ref: '#/components/examples/ErrIdempotencyKeyReuse' }
                idempotency_key_in_use: { $ref: '#/components/examples/ErrIdempotencyKeyInUse' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/senders \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: connect-sales-es-1" \
              -d '{"label":"Sales ES #1","country":"ES"}'
    get:
      tags: [Senders]
      operationId: listSenders
      summary: List senders
      description: |
        Newest first (`created_at DESC`). Includes every status. Senders that are
        `awaiting_scan` include their live `connection`, with the QR when it is
        `requires_scan`. A sender that never connected and is `disconnected` is
        deleted 24 hours after its creation, unless a campaign, a DM or a
        conversation refers to it, or its dedicated IP is still being prepared or
        waiting for its first connection (up to 72 hours).
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - name: status
          in: query
          required: false
          description: 'Only senders in this status. Anything else, including an empty or repeated value, is a 400 with `param: status`.'
          schema: { $ref: '#/components/schemas/SenderStatus' }
          example: connected
        - name: platform
          in: query
          required: false
          description: Only `tiktok` exists. Another value is a 400 `platform_not_supported`.
          schema: { type: string, enum: [tiktok] }
      responses:
        '200':
          description: A page of senders.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SenderList' }
              example:
                object: list
                data:
                  - id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                    object: sender
                    platform: tiktok
                    username: brand.es
                    label: 'Sales ES #1'
                    country: ES
                    status: connected
                    status_reason: null
                    health: 100
                    daily_limit: 30
                    sent_today: 12
                    connection: null
                    exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                    created: 1790760000
                has_more: false
                next_cursor: null
        '400':
          description: '`invalid_request` (`limit` or `status`), `platform_not_supported`, or `invalid_cursor`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_limit: { $ref: '#/components/examples/ErrLimit' }
                bad_status:
                  summary: Unknown status
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: 'status must be one of: awaiting_scan, connected, disconnected, cooldown, banned.'
                      param: status
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                invalid_cursor:
                  summary: Cursor not in this list
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_cursor
                      message: starting_after must be the id of a sender (snd_…) returned by this list.
                      param: starting_after
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl "https://api.hooka.to/v1/senders?status=connected&limit=20" \
              -H "Authorization: Bearer $HOOKA_API_KEY"
  /senders/{id}:
    parameters:
      - $ref: '#/components/parameters/SenderId'
    get:
      tags: [Senders]
      operationId: getSender
      summary: Retrieve a sender
      description: |
        The polling endpoint of the connect flow. While the sender is
        `awaiting_scan`, `connection` is read from the live login session at the
        time of the request. Stop polling when the sender becomes `connected` or
        `disconnected`, or when `connection.status` is `expired` or `failed`.
      responses:
        '200':
          description: The sender.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sender' }
              examples:
                requires_scan:
                  summary: QR ready to scan
                  value:
                    id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                    object: sender
                    platform: tiktok
                    username: null
                    label: 'Sales ES #1'
                    country: ES
                    status: awaiting_scan
                    status_reason: null
                    health: null
                    daily_limit: 30
                    sent_today: 0
                    connection:
                      status: requires_scan
                      qr_code: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
                      verification: null
                      websocket_url: wss://api.hooka.to/ws/sessions/0b6f7d7e-3f7e-4a0e-8a51-1f2e3d4c5b6a
                      error: null
                    exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                    created: 1790760000
                verification_required:
                  summary: TikTok asks "Verify it's you"
                  value:
                    id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                    object: sender
                    platform: tiktok
                    username: null
                    label: 'Sales ES #1'
                    country: ES
                    status: awaiting_scan
                    status_reason: null
                    health: null
                    daily_limit: 30
                    sent_today: 0
                    connection:
                      status: verification_required
                      qr_code: null
                      verification:
                        step: choose_method
                        title: Verify it's you
                        description: Choose a way to receive a verification code
                        options:
                          - { id: 0, kind: email, label: Email, detail: h***o@example.com }
                          - { id: 1, kind: phone, label: SMS, detail: '+34****3788' }
                        resend_available: false
                        resend_in: null
                        checking: false
                        messages: []
                      websocket_url: wss://api.hooka.to/ws/sessions/0b6f7d7e-3f7e-4a0e-8a51-1f2e3d4c5b6a
                      error: null
                    exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                    created: 1790760000
                connected:
                  summary: Connected
                  value:
                    id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                    object: sender
                    platform: tiktok
                    username: brand.es
                    label: 'Sales ES #1'
                    country: ES
                    status: connected
                    status_reason: null
                    health: 100
                    daily_limit: 30
                    sent_today: 12
                    connection: null
                    exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                    created: 1790760000
                disconnected:
                  summary: Logged out by TikTok
                  value:
                    id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                    object: sender
                    platform: tiktok
                    username: brand.es
                    label: 'Sales ES #1'
                    country: ES
                    status: disconnected
                    status_reason: session_expired
                    health: 0
                    daily_limit: 30
                    sent_today: 0
                    connection: null
                    exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                    created: 1790760000
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/SenderNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/senders/snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10 \
              -H "Authorization: Bearer $HOOKA_API_KEY"
    patch:
      tags: [Senders]
      operationId: updateSender
      summary: Update a sender's label
      description: |
        `label` is the only field you can change, in any status. Any other key is a
        400 whose `param` is that key. `country` gets its own message: a sender's
        country cannot be changed (its dedicated IP is from that country); Hooka
        support can move it. The body is validated before the sender is looked
        up, so an invalid body on an unknown id returns 400, not 404. Emits no
        event.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SenderUpdate' }
            example: { label: 'Sales ES #2' }
      responses:
        '200':
          description: The updated sender.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sender' }
              example:
                id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                object: sender
                platform: tiktok
                username: brand.es
                label: 'Sales ES #2'
                country: ES
                status: connected
                status_reason: null
                health: 100
                daily_limit: 30
                sent_today: 12
                connection: null
                exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                created: 1790760000
        '400':
          description: '`invalid_request`: body not an object, `country` (it cannot be changed), a key other than `label`, or `label` missing or invalid.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                country_locked: { $ref: '#/components/examples/ErrCountryLocked' }
                other_key:
                  summary: A field that cannot be changed
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: Only label can be updated on a sender ("daily_limit" is not accepted).
                      param: daily_limit
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                missing_label:
                  summary: label missing
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: label must be a string of up to 80 characters, or null to clear it.
                      param: label
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/TestKeyReadOnly' }
        '404': { $ref: '#/components/responses/SenderNotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl -X PATCH https://api.hooka.to/v1/senders/snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10 \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"label":"Sales ES #2"}'
    delete:
      tags: [Senders]
      operationId: deleteSender
      summary: Disconnect a sender
      description: |
        Disconnects the sender. **The sender is not deleted**: it stays in the list
        as `disconnected` with `status_reason: disconnected_by_api` and `health: 0`,
        frees its seat, and can be reconnected with
        `POST /senders/{id}/connection/refresh`.

        Effects:
        - A QR login in progress for this sender is cut; scanning that QR afterwards
          does nothing.
        - Running campaigns left with no connected sender are paused (no event).
        - Every `pending` follow-up that only this sender could send (it sent the
          previous step) is skipped with `sender_unavailable`, in every campaign,
          with one `target.skipped` each; campaigns left with nothing to send
          complete.
        - `sender.status_changed` is emitted only if the sender was `connected`,
          `cooldown` or `banned`.

        Repeating the call returns the same body.
      responses:
        '200':
          description: Disconnected.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SenderDisconnected' }
              example: { id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10, object: sender, deleted: true, status: disconnected }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/TestKeyReadOnly' }
        '404': { $ref: '#/components/responses/SenderNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl -X DELETE https://api.hooka.to/v1/senders/snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10 \
              -H "Authorization: Bearer $HOOKA_API_KEY"
  /senders/{id}/connection/refresh:
    parameters:
      - $ref: '#/components/parameters/SenderId'
    post:
      tags: [Senders]
      operationId: refreshSenderConnection
      summary: Start a new QR attempt
      description: |
        Starts a new QR login attempt for an existing sender that is
        `awaiting_scan` (restarts it), `disconnected`, `cooldown` or `banned`. The
        sender keeps its `snd_` id, its country and its dedicated IP (unless a new
        sender in the same country took the IP over because the plan had no seat
        for both); the response
        is the sender with `connection.status: pending` and no QR yet, as on
        create. If the IP is not ready, Hooka prepares it (or keeps waiting for the
        one already being prepared: a refresh never throws it away) and
        `exit_ip.status` is `provisioning`.

        The body is optional. Only `country` is read, and only for a sender that
        has no country yet (connected before per-sender IPs); otherwise the same
        country is ignored and a different one is a 400 `invalid_request` with
        `param: country`. A non-empty body must be JSON with
        `Content-Type: application/json` (otherwise 415), and it counts toward the
        `Idempotency-Key` match.

        **Checks, in order:** a valid `country` (400), the sender exists (404),
        `already_connected` (409), `no_active_subscription` (403), the country (400
        `invalid_request` if it differs, `country_required` if there is none), a
        free seat when the sender is `disconnected`, `cooldown` or `banned` (403
        `sender_limit_reached`; senders paused for `plan_limit` don't count), and
        an IP (409 `exit_ip_not_ready`).

        **Side effects:** clears `status_reason`; cancels every other connection
        attempt in progress in the workspace, as on create. The sender's own
        previous attempt is replaced silently.

        **Same TikTok account only.** If the sender already has a `username` and
        the new QR is scanned with another TikTok account, the login is rejected
        and the attempt ends with `wrong_account`; the sender keeps its account.
        A sender that never connected accepts any account not already connected
        in another sender of the workspace (`duplicate_account`).

        **Idempotency:** supported. Not stored: `already_connected`,
        `no_active_subscription`, `country_required`, `sender_limit_reached`,
        `exit_ip_not_ready`, 429 and 5xx.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SenderRefresh' }
            example: { country: ES }
      responses:
        '200':
          description: The sender, `awaiting_scan`, with a new pending attempt.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sender' }
              example:
                id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                object: sender
                platform: tiktok
                username: brand.es
                label: 'Sales ES #1'
                country: ES
                status: awaiting_scan
                status_reason: null
                health: 0
                daily_limit: 30
                sent_today: 0
                connection:
                  status: pending
                  qr_code: null
                  verification: null
                  websocket_url: wss://api.hooka.to/ws/sessions/7d1e2f3a-4b5c-4d6e-8f7a-9b0c1d2e3f4a
                  error: null
                exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                created: 1790760000
        '400':
          description: '`invalid_request` (bad `country`, or one different from the sender''s), `country_required`, `invalid_json` or `invalid_idempotency_key`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_country: { $ref: '#/components/examples/ErrInvalidCountry' }
                country_locked: { $ref: '#/components/examples/ErrCountryLocked' }
                country_required:
                  summary: Sender without a country and no default
                  value:
                    error:
                      type: invalid_request_error
                      code: country_required
                      message: 'Pass country (ISO 3166-1 alpha-2): this sender has no country yet and the workspace has no confirmed default country.'
                      param: country
                      suggested_country: ES
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                invalid_json: { $ref: '#/components/examples/ErrInvalidJson' }
                invalid_idempotency_key: { $ref: '#/components/examples/ErrInvalidIdempotencyKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: '`no_active_subscription`, `sender_limit_reached` or `test_key_read_only`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                no_active_subscription: { $ref: '#/components/examples/ErrNoActiveSubscription' }
                sender_limit_reached: { $ref: '#/components/examples/ErrSenderLimitReached' }
                test_key_read_only: { $ref: '#/components/examples/ErrTestKeyReadOnly' }
        '404': { $ref: '#/components/responses/SenderNotFound' }
        '409':
          description: '`already_connected`, `exit_ip_not_ready`, `idempotency_key_reuse` or `idempotency_key_in_use`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                already_connected:
                  summary: Sender already connected
                  value:
                    error: { type: conflict_error, code: already_connected, message: This sender is already connected., request_id: req_9eab538a53ad4f74840f1ee1927578d3 }
                exit_ip_not_ready: { $ref: '#/components/examples/ErrExitIpCoolingDown' }
                idempotency_key_reuse: { $ref: '#/components/examples/ErrIdempotencyKeyReuse' }
                idempotency_key_in_use: { $ref: '#/components/examples/ErrIdempotencyKeyInUse' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl -X POST https://api.hooka.to/v1/senders/snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10/connection/refresh \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Idempotency-Key: reconnect-sales-es-1-attempt-2"
  /senders/{id}/connection/verify:
    parameters:
      - $ref: '#/components/parameters/SenderId'
    post:
      tags: [Senders]
      operationId: verifySenderConnection
      summary: Answer TikTok's "Verify it's you"
      description: |
        Use it while `connection.status` is `verification_required`. Read
        `connection.verification.step`; TikTok decides which step comes first.

        | `step` | What to send |
        |---|---|
        | `choose_method` | `{"action":"choose_method","option_id":<id>}` with an `id` from `verification.options`. The step then becomes `enter_code`. |
        | `enter_code` | `{"action":"submit_code","code":"123456"}`, or `{"action":"resend_code"}` when `resend_available` is true. |
        | `finishing` | Nothing. TikTok is completing the login. |
        | `unsupported` | Nothing works: TikTok asked for something Hooka cannot handle (captcha, password…). The attempt ends at its deadline with `verification_unsupported`; call `connection/refresh` or `DELETE` to abandon it sooner. |

        **202 means queued, not applied.** Hooka's browser performs the action in
        TikTok's real window within seconds. The response is the sender as
        it was **before** the action. An action that turns out not to apply (the
        option list changed, the window closed) fails silently: read the result in
        `GET /senders/{id}`. A wrong code keeps `step: enter_code` and puts TikTok's
        error text in `verification.messages`, with **no new webhook**; poll to see
        it.

        The API accepts codes of 4–12 letters or digits (whitespace removed), but
        only the first 8 characters are typed into TikTok.

        Validation happens before the sender is looked up, so an invalid body on an
        unknown id returns 400.

        **Idempotency:** supported. Use a new key for each code. Not stored:
        `not_verifying`, `verification_action_unavailable`, the `option_id` 400 for
        an option that is not on screen now, 429 and 5xx.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VerifyAction' }
            examples:
              choose_method:
                summary: Receive the code by email
                value: { action: choose_method, option_id: 0 }
              submit_code:
                summary: Submit the code
                value: { action: submit_code, code: '482913' }
              resend_code:
                summary: Ask TikTok for a new code
                value: { action: resend_code }
      responses:
        '202':
          description: Action queued. The body is the sender as it was before the action ran.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sender' }
              example:
                id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                object: sender
                platform: tiktok
                username: null
                label: 'Sales ES #1'
                country: ES
                status: awaiting_scan
                status_reason: null
                health: null
                daily_limit: 30
                sent_today: 0
                connection:
                  status: verification_required
                  qr_code: null
                  verification:
                    step: enter_code
                    title: Enter 6-digit code
                    description: Your code was sent to h***o@example.com
                    options: []
                    resend_available: false
                    resend_in: 55
                    checking: false
                    messages: []
                  websocket_url: wss://api.hooka.to/ws/sessions/0b6f7d7e-3f7e-4a0e-8a51-1f2e3d4c5b6a
                  error: null
                exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                created: 1790760000
        '400':
          description: |
            `invalid_request`: the body is not an object, `action` is unknown,
            `option_id` is not a non-negative integer or not among the current
            `verification.options`, or `code` is not 4–12 letters or digits. Also
            `invalid_json` and `invalid_idempotency_key`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_action:
                  summary: Unknown action
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: 'action must be one of: choose_method, submit_code, resend_code.'
                      param: action
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                bad_code:
                  summary: Code with the wrong shape
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: 'code must be the code TikTok sent: 4 to 12 letters or digits.'
                      param: code
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                option_not_on_screen:
                  summary: option_id not among the current options
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: option_id must be the id of one of connection.verification.options (the current step is enter_code).
                      param: option_id
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/TestKeyReadOnly' }
        '404': { $ref: '#/components/responses/SenderNotFound' }
        '409':
          description: |
            `not_verifying` (the sender is not in `verification_required`),
            `verification_action_unavailable` (`submit_code`/`resend_code` outside
            `enter_code`, or `resend_code` while `resend_available` is false),
            `idempotency_key_reuse` or `idempotency_key_in_use`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                not_verifying:
                  summary: No verification in progress
                  value:
                    error:
                      type: conflict_error
                      code: not_verifying
                      message: This sender is not waiting for a verification step. This endpoint only works while connection.status is verification_required.
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                resend_not_yet:
                  summary: Resend not allowed yet
                  value:
                    error:
                      type: conflict_error
                      code: verification_action_unavailable
                      message: TikTok does not allow resending the code yet; retry in 55 s (see connection.verification.resend_available).
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                idempotency_key_reuse: { $ref: '#/components/examples/ErrIdempotencyKeyReuse' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429':
          description: '`too_many_actions` (20 actions already queued for this attempt; retry in 2 s), or the workspace rate limit (`rate_limited`).'
          headers:
            Retry-After: { $ref: '#/components/headers/RetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                too_many_actions:
                  summary: Action queue full
                  value:
                    error:
                      type: rate_limit_error
                      code: too_many_actions
                      message: Too many verification actions are waiting to be applied. Retry in 2 s.
                      retry_after: 2
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                rate_limited: { $ref: '#/components/examples/ErrRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl (choose method)
          source: |
            curl https://api.hooka.to/v1/senders/snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10/connection/verify \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: verify-sales-es-1-method" \
              -d '{"action":"choose_method","option_id":0}'
        - lang: Shell
          label: curl (submit code)
          source: |
            curl https://api.hooka.to/v1/senders/snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10/connection/verify \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: verify-sales-es-1-code-482913" \
              -d '{"action":"submit_code","code":"482913"}'

  # ───────────────────────────── Campaigns ─────────────────────────────
  /campaigns:
    post:
      tags: [Campaigns]
      operationId: createCampaign
      summary: Create a campaign
      description: |
        Creates a campaign in `draft`, optionally with up to 1000 targets inline.
        Nothing is sent until you call `start`, and no event is emitted.

        **Senders.** With `settings.sender_ids` (1–10), those senders are used, in
        any status (`start` checks that one is connected). Without it, the campaign
        takes the **10 oldest connected senders** of the workspace. Senders
        connected later are not added automatically. The API cannot change the set
        after creation; the dashboard can, and `settings.sender_ids` always shows
        the current set.

        Inline targets are inserted in the same transaction as the campaign, with
        the same normalization and skip rules as `POST /campaigns/{id}/targets`.

        **Validation order:** body schema, `platform`, settings consistency
        (`daily_limit` vs its alias, `stop_on_reply`, `sender_pool`), then senders.

        **Idempotency:** supported. Not stored: `no_sender_available`, 429 and 5xx.
        Every other 4xx is stored and replayed for 24 hours.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CampaignCreate' }
            example:
              name: Creators ES - October
              client_reference: crm-4412
              message:
                variants:
                  - Hola {{first_name}}, vi tu último vídeo y me encantó. ¿Te interesaría colaborar con nosotros?
                  - ¡Hola {{first_name}}! Nos gusta mucho tu contenido. ¿Hablamos de una colaboración?
              follow_ups:
                - wait_days: 3
                  variants:
                    - '{{first_name}}, ¿pudiste ver mi mensaje?'
              targets:
                - username: creator.one
                  variables: { first_name: Lucía }
                - username: creator.two
                  variables: { first_name: Mia }
              settings:
                sender_ids: [snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10]
                rate_limit_seconds: 90
                daily_limit: 40
                on_missing_variable: skip
      responses:
        '201':
          description: The campaign (without `stats`), plus the result of the inline targets.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignCreated' }
              example:
                id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                object: campaign
                platform: tiktok
                name: Creators ES - October
                client_reference: crm-4412
                status: draft
                message:
                  variants:
                    - Hola {{first_name}}, vi tu último vídeo y me encantó. ¿Te interesaría colaborar con nosotros?
                    - ¡Hola {{first_name}}! Nos gusta mucho tu contenido. ¿Hablamos de una colaboración?
                follow_ups:
                  - wait_days: 3
                    variants:
                      - '{{first_name}}, ¿pudiste ver mi mensaje?'
                settings:
                  rate_limit_seconds: 90
                  daily_limit: 40
                  daily_limit_per_sender: 40
                  dedupe_across_campaigns: true
                  on_missing_variable: skip
                  sender_ids: [snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10]
                created: 1790762400
                targets_accepted: 2
                target_ids:
                  - tgt_9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d
                  - tgt_7c2d4e6f-8a0b-4c1d-9e2f-3a4b5c6d7e8f
                targets_skipped: []
        '400':
          description: |
            - `invalid_request`: the first schema problem, with `param` (for example
              `name`, `message`, `message.variants.2`, `targets.4`,
              `follow_ups.0.wait_days`, `settings.rate_limit_seconds`), or a
              settings conflict (`settings.daily_limit`, `settings.sender_pool`).
            - `platform_not_supported`: `platform` is not exactly `tiktok`.
            - `unsupported_setting`: `settings.stop_on_reply: false`.
            - `sender_invalid` / `sender_not_found`: a `settings.sender_ids` item is
              malformed / not a sender of this workspace.
            - `no_sender_available`: no `sender_ids` and no connected sender in the
              workspace (not stored by idempotency).
            - `invalid_json`, `invalid_idempotency_key`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                message_shape:
                  summary: message has the wrong shape
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: 'message must be a string (1-2000 chars) or {"variants": [1-20 strings of 1-2000 chars]}'
                      param: message
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                bad_target:
                  summary: A target item is not a string or object
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: 'targets[4] is not valid: each target must be a username string ("creator") or an object like {"username": "creator", "variables": {"first_name": "Mia"}}.'
                      param: targets.4
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                rate_too_low:
                  summary: rate_limit_seconds below 30
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: Number must be greater than or equal to 30
                      param: settings.rate_limit_seconds
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                unsupported_setting:
                  summary: stop_on_reply false
                  value:
                    error:
                      type: invalid_request_error
                      code: unsupported_setting
                      message: Follow-ups always stop when the recipient replies; stop_on_reply:false is not supported.
                      param: settings.stop_on_reply
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                sender_not_found:
                  summary: A sender id of another workspace
                  value:
                    error:
                      type: invalid_request_error
                      code: sender_not_found
                      message: One or more senders were not found in this workspace.
                      param: settings.sender_ids
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                no_sender_available:
                  summary: No connected sender in the workspace
                  value:
                    error:
                      type: invalid_request_error
                      code: no_sender_available
                      message: No connected sender in this workspace. Connect one via POST /v1/senders.
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                platform_not_supported: { $ref: '#/components/examples/ErrPlatformNotSupported' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/TestKeyReadOnly' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/campaigns \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: create-campaign-crm-4412" \
              -d '{
                "name": "Creators ES - October",
                "client_reference": "crm-4412",
                "message": {"variants": [
                  "Hola {{first_name}}, vi tu último vídeo y me encantó. ¿Te interesaría colaborar con nosotros?",
                  "¡Hola {{first_name}}! Nos gusta mucho tu contenido. ¿Hablamos de una colaboración?"
                ]},
                "follow_ups": [{"wait_days": 3, "variants": ["{{first_name}}, ¿pudiste ver mi mensaje?"]}],
                "targets": [
                  {"username": "creator.one", "variables": {"first_name": "Lucía"}},
                  {"username": "creator.two", "variables": {"first_name": "Mia"}}
                ],
                "settings": {
                  "sender_ids": ["snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10"],
                  "rate_limit_seconds": 90,
                  "daily_limit": 40,
                  "on_missing_variable": "skip"
                }
              }'
    get:
      tags: [Campaigns]
      operationId: listCampaigns
      summary: List campaigns
      description: |
        Newest first. Excludes `archived` campaigns (they are still readable by id).
        Items do **not** include `stats`; use `GET /campaigns/{id}`.

        There are no filters: `status`, `platform`, `client_reference` or any other
        query parameter is ignored (even `platform=instagram` returns 200).
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: A page of campaigns.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignList' }
              example:
                object: list
                data:
                  - id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                    object: campaign
                    platform: tiktok
                    name: Creators ES - October
                    client_reference: crm-4412
                    status: running
                    message:
                      variants:
                        - Hola {{first_name}}, vi tu último vídeo y me encantó. ¿Te interesaría colaborar con nosotros?
                        - ¡Hola {{first_name}}! Nos gusta mucho tu contenido. ¿Hablamos de una colaboración?
                    follow_ups:
                      - wait_days: 3
                        variants:
                          - '{{first_name}}, ¿pudiste ver mi mensaje?'
                    settings:
                      rate_limit_seconds: 90
                      daily_limit: 40
                      daily_limit_per_sender: 40
                      dedupe_across_campaigns: true
                      on_missing_variable: skip
                      sender_ids: [snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10]
                    created: 1790762400
                has_more: false
                next_cursor: null
        '400':
          description: '`invalid_request` (`limit`) or `invalid_cursor`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_limit: { $ref: '#/components/examples/ErrLimit' }
                invalid_cursor:
                  summary: Cursor not in this list
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_cursor
                      message: starting_after must be the id of a campaign (cmp_…) returned by this list.
                      param: starting_after
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl "https://api.hooka.to/v1/campaigns?limit=20" \
              -H "Authorization: Bearer $HOOKA_API_KEY"
  /campaigns/{id}:
    parameters:
      - $ref: '#/components/parameters/CampaignId'
    get:
      tags: [Campaigns]
      operationId: getCampaign
      summary: Retrieve a campaign, with live stats
      description: |
        Returns the campaign with `stats` computed at request time, in any status
        (`archived` included).

        There is no `PATCH` or `DELETE` for campaigns: edit or delete them in the
        dashboard.
      responses:
        '200':
          description: The campaign, with `stats`.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignWithStats' }
              example:
                id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                object: campaign
                platform: tiktok
                name: Creators ES - October
                client_reference: crm-4412
                status: running
                message:
                  variants:
                    - Hola {{first_name}}, vi tu último vídeo y me encantó. ¿Te interesaría colaborar con nosotros?
                    - ¡Hola {{first_name}}! Nos gusta mucho tu contenido. ¿Hablamos de una colaboración?
                follow_ups:
                  - wait_days: 3
                    variants:
                      - '{{first_name}}, ¿pudiste ver mi mensaje?'
                settings:
                  rate_limit_seconds: 90
                  daily_limit: 40
                  daily_limit_per_sender: 40
                  dedupe_across_campaigns: true
                  on_missing_variable: skip
                  sender_ids: [snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10]
                stats:
                  total: 120
                  queued: 70
                  pending: 70
                  processing: 1
                  sent: 44
                  replied: 6
                  failed: 3
                  skipped: 2
                  reply_rate: 0.143
                  not_delivered: 1
                created: 1790762400
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/CampaignNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/campaigns/cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41 \
              -H "Authorization: Bearer $HOOKA_API_KEY"
  /campaigns/{id}/start:
    parameters:
      - $ref: '#/components/parameters/CampaignId'
    post:
      tags: [Campaigns]
      operationId: startCampaign
      summary: Start a campaign
      description: |
        Moves the campaign to `running`. **Real DMs start going out** within
        seconds or minutes, subject to pacing and daily caps.

        Allowed from `draft`, `paused`, or `completed` with at least one `pending`
        target. At least one of the campaign's senders must be `connected`, and the
        workspace must not be paused: with the last invoice unpaid or the
        subscription ended, 403 `no_active_subscription` (see
        `subscription_status`) and nothing changes.

        Send no body (it is not used). A non-empty body must still be JSON with
        `Content-Type: application/json` (otherwise 415), and it counts toward the
        `Idempotency-Key` match.

        `campaign.started` is emitted when the campaign comes from `draft` or
        `completed`, not from `paused`.

        **Idempotency:** supported (recommended). Not stored:
        `invalid_campaign_state`, `no_sender_available`, `no_active_subscription`, 429 and 5xx.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The campaign, now `running`, with `stats`.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignWithStats' }
              example:
                id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                object: campaign
                platform: tiktok
                name: Creators ES - October
                client_reference: crm-4412
                status: running
                message:
                  variants:
                    - Hola {{first_name}}, vi tu último vídeo y me encantó. ¿Te interesaría colaborar con nosotros?
                    - ¡Hola {{first_name}}! Nos gusta mucho tu contenido. ¿Hablamos de una colaboración?
                follow_ups:
                  - wait_days: 3
                    variants:
                      - '{{first_name}}, ¿pudiste ver mi mensaje?'
                settings:
                  rate_limit_seconds: 90
                  daily_limit: 40
                  daily_limit_per_sender: 40
                  dedupe_across_campaigns: true
                  on_missing_variable: skip
                  sender_ids: [snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10]
                stats: { total: 2, queued: 2, pending: 2, processing: 0, sent: 0, replied: 0, failed: 0, skipped: 0, reply_rate: 0 }
                created: 1790762400
        '400': { $ref: '#/components/responses/LifecycleBadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/NoServiceOrTestKey' }
        '404': { $ref: '#/components/responses/CampaignNotFound' }
        '409': { $ref: '#/components/responses/LifecycleConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl -X POST https://api.hooka.to/v1/campaigns/cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41/start \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Idempotency-Key: start-campaign-crm-4412"
  /campaigns/{id}/pause:
    parameters:
      - $ref: '#/components/parameters/CampaignId'
    post:
      tags: [Campaigns]
      operationId: pauseCampaign
      summary: Pause a campaign
      description: |
        Moves a `running` campaign to `paused`. No new target is claimed while it is
        paused; a send already `processing` finishes and its result is recorded (if
        that was the last open target, the campaign completes). Follow-ups that
        become due while paused are created after `resume`. Emits no event.

        Send no body (it is not used). A non-empty body must still be JSON with
        `Content-Type: application/json` (otherwise 415), and it counts toward the
        `Idempotency-Key` match.

        **Idempotency:** supported. Not stored: `invalid_campaign_state`, 429 and
        5xx.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The campaign, now `paused`, with `stats`.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignWithStats' }
              example:
                id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                object: campaign
                platform: tiktok
                name: Creators ES - October
                client_reference: crm-4412
                status: paused
                message:
                  variants:
                    - Hola {{first_name}}, vi tu último vídeo y me encantó. ¿Te interesaría colaborar con nosotros?
                    - ¡Hola {{first_name}}! Nos gusta mucho tu contenido. ¿Hablamos de una colaboración?
                follow_ups:
                  - wait_days: 3
                    variants:
                      - '{{first_name}}, ¿pudiste ver mi mensaje?'
                settings:
                  rate_limit_seconds: 90
                  daily_limit: 40
                  daily_limit_per_sender: 40
                  dedupe_across_campaigns: true
                  on_missing_variable: skip
                  sender_ids: [snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10]
                stats: { total: 120, queued: 70, pending: 70, processing: 1, sent: 44, replied: 6, failed: 3, skipped: 2, reply_rate: 0.143 }
                created: 1790762400
        '400':
          description: '`invalid_json` (a body was sent and it is not valid JSON) or `invalid_idempotency_key`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                invalid_json: { $ref: '#/components/examples/ErrInvalidJson' }
                invalid_idempotency_key: { $ref: '#/components/examples/ErrInvalidIdempotencyKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/TestKeyReadOnly' }
        '404': { $ref: '#/components/responses/CampaignNotFound' }
        '409': { $ref: '#/components/responses/LifecycleConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl -X POST https://api.hooka.to/v1/campaigns/cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41/pause \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Idempotency-Key: pause-campaign-crm-4412-1"
  /campaigns/{id}/resume:
    parameters:
      - $ref: '#/components/parameters/CampaignId'
    post:
      tags: [Campaigns]
      operationId: resumeCampaign
      summary: Resume a campaign
      description: |
        Moves the campaign back to `running` from `paused`, or from `completed`
        when it has at least one `pending` target (for example targets added while
        no sender was connected). At least one of the campaign's senders must be
        `connected`. **Real DMs start going out again.**

        Send no body (it is not used). A non-empty body must still be JSON with
        `Content-Type: application/json` (otherwise 415), and it counts toward the
        `Idempotency-Key` match.

        Use it after a pause caused by disconnected senders: Hooka never resumes a
        campaign by itself when a sender reconnects.

        `campaign.started` is emitted only when resuming from `completed`.

        **Idempotency:** supported (recommended). Not stored:
        `invalid_campaign_state`, `no_sender_available`, 429 and 5xx.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: The campaign, now `running`, with `stats`.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignWithStats' }
              example:
                id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                object: campaign
                platform: tiktok
                name: Creators ES - October
                client_reference: crm-4412
                status: running
                message:
                  variants:
                    - Hola {{first_name}}, vi tu último vídeo y me encantó. ¿Te interesaría colaborar con nosotros?
                    - ¡Hola {{first_name}}! Nos gusta mucho tu contenido. ¿Hablamos de una colaboración?
                follow_ups:
                  - wait_days: 3
                    variants:
                      - '{{first_name}}, ¿pudiste ver mi mensaje?'
                settings:
                  rate_limit_seconds: 90
                  daily_limit: 40
                  daily_limit_per_sender: 40
                  dedupe_across_campaigns: true
                  on_missing_variable: skip
                  sender_ids: [snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10]
                stats: { total: 120, queued: 70, pending: 70, processing: 0, sent: 45, replied: 6, failed: 3, skipped: 2, reply_rate: 0.14 }
                created: 1790762400
        '400': { $ref: '#/components/responses/LifecycleBadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/NoServiceOrTestKey' }
        '404': { $ref: '#/components/responses/CampaignNotFound' }
        '409': { $ref: '#/components/responses/LifecycleConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl -X POST https://api.hooka.to/v1/campaigns/cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41/resume \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Idempotency-Key: resume-campaign-crm-4412-1"

  # ───────────────────────────── Targets ─────────────────────────────
  /campaigns/{id}/targets:
    parameters:
      - $ref: '#/components/parameters/CampaignId'
    post:
      tags: [Targets]
      operationId: addTargets
      summary: Add targets to a campaign
      description: |
        Adds 1–1000 targets as step-0 rows with status `pending`. Items are
        normalized and classified (see *Targets*); skipped items create no target
        and no event. Concurrent calls to the same campaign are serialized.

        **Effect by campaign status** (only when `accepted > 0`):

        | Campaign | What happens |
        |---|---|
        | `running` | The new targets can be sent right away: **real DMs** (nothing is sent while the workspace has no service). |
        | `draft`, `paused` | They wait. Adding targets never starts or resumes a campaign. |
        | `completed`, with a connected sender | The campaign goes back to `running` (`resumed: true`) and `campaign.started` is emitted. |
        | `completed`, no connected sender | It stays `completed`, `resumed: false`, `resume_blocked_reason: no_connected_sender`. Reconnect a sender, then call `start` or `resume`. |
        | `completed`, workspace without service | The last invoice is unpaid or the subscription ended (`subscription.status` `unpaid` or `ended`): it stays `completed`, `resumed: false`, `resume_blocked_reason: no_active_subscription`. Once the subscription is paid, call `start` or `resume`. |
        | `archived` | Accepted, but never sent while the campaign stays archived. |

        **Idempotency:** supported (recommended). Not stored: 429 and 5xx.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TargetsAdd' }
            example:
              targets:
                - creator.three
                - username: '@Creator.Four'
                  variables: { first_name: Leo, followers: 12000 }
                - creator.two
                - bad handle!
      responses:
        '202':
          description: Targets accepted and skipped.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TargetsAdded' }
              examples:
                running:
                  summary: Running campaign
                  value:
                    accepted: 2
                    target_ids:
                      - tgt_2e3f4a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b
                      - tgt_3f4a5b6c-7d8e-4f9a-8b1c-2d3e4f5a6b7c
                    skipped:
                      - { username: creator.two, reason: duplicate_in_campaign }
                      - { username: bad handle!, reason: invalid }
                    campaign_status: running
                    resumed: false
                blocked:
                  summary: Completed campaign with no connected sender
                  value:
                    accepted: 1
                    target_ids: [tgt_4a5b6c7d-8e9f-4a0b-9c2d-3e4f5a6b7c8d]
                    skipped: []
                    campaign_status: completed
                    resumed: false
                    resume_blocked_reason: no_connected_sender
        '400':
          description: '`invalid_request` (`targets` missing, empty, over 1000, or a bad item `targets.N`), `invalid_json` or `invalid_idempotency_key`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_array:
                  summary: targets missing or empty
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: targets must be a non-empty array (max 1000).
                      param: targets
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                bad_item:
                  summary: A target item is not valid
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: 'targets[0] is not valid: each target must be a username string ("creator") or an object like {"username": "creator", "variables": {"first_name": "Mia"}}.'
                      param: targets.0
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/TestKeyReadOnly' }
        '404': { $ref: '#/components/responses/CampaignNotFound' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/campaigns/cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41/targets \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: add-targets-crm-4412-batch-2" \
              -d '{"targets": [
                "creator.three",
                {"username": "@Creator.Four", "variables": {"first_name": "Leo", "followers": 12000}}
              ]}'
    get:
      tags: [Targets]
      operationId: listTargets
      summary: List a campaign's targets
      description: |
        Oldest first, in the order targets were added, which is also the order
        initial DMs are sent. Follow-up rows are created when they become due, so
        they appear after the rows that existed at that time. Works on archived
        campaigns.

        There is no `step` filter and no single-target endpoint.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - name: status
          in: query
          required: false
          description: |
            Only targets in this status. `queued` is accepted as an alias of
            `pending`. Any other value (including `replied`, an empty or a repeated
            value) is a 400 with `param: status`.
          schema:
            type: string
            enum: [pending, queued, processing, sent, failed, skipped]
          example: failed
      responses:
        '200':
          description: A page of targets.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TargetList' }
              example:
                object: list
                data:
                  - id: tgt_9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d
                    object: target
                    campaign_id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                    username: creator.one
                    variables: { first_name: Lucía }
                    status: sent
                    step: 0
                    sent_at: 1790781240
                    error: null
                    error_message: null
                    delivery: { status: verified, checked_at: 1790781550, reason: null, detail: null }
                  - id: tgt_7c2d4e6f-8a0b-4c1d-9e2f-3a4b5c6d7e8f
                    object: target
                    campaign_id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                    username: creator.two
                    variables: { first_name: Mia }
                    status: failed
                    step: 0
                    sent_at: null
                    error: recipient_privacy
                    error_message: No se puede enviar el mensaje debido a los ajustes de privacidad del usuario
                    delivery: null
                has_more: false
                next_cursor: null
        '400':
          description: '`invalid_request` (`limit` or `status`) or `invalid_cursor`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_limit: { $ref: '#/components/examples/ErrLimit' }
                bad_status:
                  summary: Unknown status
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: 'status must be one of: pending, processing, sent, failed, skipped (queued is accepted as an alias of pending).'
                      param: status
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                invalid_cursor:
                  summary: Cursor not in this list
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_cursor
                      message: starting_after must be the id of a target of this campaign (tgt_…) returned by this list.
                      param: starting_after
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/CampaignNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl "https://api.hooka.to/v1/campaigns/cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41/targets?status=failed&limit=100" \
              -H "Authorization: Bearer $HOOKA_API_KEY"

  # ───────────────────────────── Suppressions ─────────────────────────────
  /suppressions:
    post:
      tags: [Suppressions]
      operationId: addSuppressions
      summary: Add handles to the Do-Not-Contact list
      description: |
        Each username is normalized (trimmed, leading `@` removed, lowercased) and
        validated like a target handle. Invalid ones are not stored and are echoed
        in `invalid` exactly as sent. `reason` (default `manual`) applies only to
        newly added handles.

        Before responding, every `pending` target with one of these handles, in any
        campaign, becomes `skipped` with `error: suppressed` (one `target.skipped`
        each, with `sender_id: null`). This also runs for handles that were already
        on the list. Returns 201 even when every username was invalid.

        **Idempotency:** supported. Not stored: 429 and 5xx.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SuppressionsAdd' }
            example:
              usernames: ['@Creator.Two', creator.five, https://tiktok.com/@x]
              reason: asked not to be contacted
      responses:
        '201':
          description: Result of the batch.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuppressionBatch' }
              example:
                object: suppression_batch
                added: 1
                already_present: 1
                invalid: [https://tiktok.com/@x]
        '400':
          description: '`invalid_request` on `usernames`, `usernames.N` or `reason`; `invalid_json`; `invalid_idempotency_key`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                missing:
                  summary: usernames missing
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: 'usernames is required: an array of 1-1000 TikTok usernames.'
                      param: usernames
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                too_long_reason:
                  summary: reason over 120 characters
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: reason must be at most 120 characters.
                      param: reason
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/TestKeyReadOnly' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/suppressions \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: dnc-import-2026-09-30" \
              -d '{"usernames": ["@Creator.Two", "creator.five"], "reason": "asked not to be contacted"}'
    get:
      tags: [Suppressions]
      operationId: listSuppressions
      summary: List suppressions
      description: Newest first. Includes handles added from the dashboard and by opt-out.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: A page of suppressions.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuppressionList' }
              example:
                object: list
                data:
                  - id: sup_8d7c6b5a-4e3f-4a2b-9c1d-0e9f8a7b6c5d
                    object: suppression
                    username: creator.five
                    reason: asked not to be contacted
                    created: 1790790000
                  - id: sup_2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e
                    object: suppression
                    username: creator.two
                    reason: manual
                    created: 1790785000
                  - id: sup_1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f
                    object: suppression
                    username: lead_handle
                    reason: opt_out
                    created: 1790700000
                has_more: false
                next_cursor: null
        '400':
          description: '`invalid_request` (`limit`) or `invalid_cursor`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_limit: { $ref: '#/components/examples/ErrLimit' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl "https://api.hooka.to/v1/suppressions?limit=100" \
              -H "Authorization: Bearer $HOOKA_API_KEY"
  /suppressions/{username}:
    parameters:
      - name: username
        in: path
        required: true
        description: The handle to remove, URL-encoded. It is normalized before matching (`@Creator.Two` matches `creator.two`).
        schema: { type: string }
        example: creator.two
    delete:
      tags: [Suppressions]
      operationId: removeSuppression
      summary: Remove a handle from the Do-Not-Contact list
      description: |
        Removes the handle. Targets that were already skipped as `suppressed` stay
        skipped, and cannot be re-added to the same campaign. A second call returns
        404.
      responses:
        '200':
          description: Removed.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SuppressionDeleted' }
              example: { username: creator.two, object: suppression, deleted: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/TestKeyReadOnly' }
        '404':
          description: '`not_found`: the handle is not on the list.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: { type: not_found_error, code: not_found, message: No such suppression., request_id: req_9eab538a53ad4f74840f1ee1927578d3 }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl -X DELETE https://api.hooka.to/v1/suppressions/creator.two \
              -H "Authorization: Bearer $HOOKA_API_KEY"

  # ───────────────────────────── Conversations ─────────────────────────────
  /conversations:
    get:
      tags: [Conversations]
      operationId: listConversations
      summary: List conversations
      description: |
        Ordered by last activity (most recent message first; conversations with no
        message time last). Because that order changes as messages arrive, a
        conversation can move between pages while you paginate; track new activity
        with `message.received` instead.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
        - name: status
          in: query
          required: false
          description: Only conversations in this status. Another value (including an empty or repeated one) is a 400.
          schema: { $ref: '#/components/schemas/ConversationStatus' }
          example: open
        - name: sender_id
          in: query
          required: false
          description: Only conversations of this sender (`snd_…` or a bare UUID). A malformed value is a 400; an unknown sender returns an empty list.
          schema: { type: string }
          example: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
      responses:
        '200':
          description: A page of conversations.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConversationList' }
              example:
                object: list
                data:
                  - id: con_1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
                    object: conversation
                    sender_id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                    username: creator.one
                    status: open
                    unread_count: 1
                    last_message: { direction: in, text: '¡Hola! Sí, cuéntame más', at: 1790781500 }
                    created: 1790781300
                has_more: false
                next_cursor: null
        '400':
          description: '`invalid_request` (`limit`, `status` or `sender_id`) or `invalid_cursor`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_status:
                  summary: Unknown status
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: 'status must be one of: open, interested, not_interested, archived.'
                      param: status
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                bad_sender_id:
                  summary: Malformed sender_id
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: sender_id must be the id of a sender (snd_…).
                      param: sender_id
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl "https://api.hooka.to/v1/conversations?status=open&limit=20" \
              -H "Authorization: Bearer $HOOKA_API_KEY"
  /conversations/{id}:
    parameters:
      - $ref: '#/components/parameters/ConversationId'
    get:
      tags: [Conversations]
      operationId: getConversation
      summary: Retrieve a conversation
      description: Useful to resolve the sender and handle of a `message.received` event from its `conversation_id`.
      responses:
        '200':
          description: The conversation.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Conversation' }
              example:
                id: con_1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
                object: conversation
                sender_id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                username: creator.one
                status: open
                unread_count: 1
                last_message: { direction: in, text: '¡Hola! Sí, cuéntame más', at: 1790781500 }
                created: 1790781300
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/ConversationNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/conversations/con_1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d \
              -H "Authorization: Bearer $HOOKA_API_KEY"
  /conversations/{id}/messages:
    parameters:
      - $ref: '#/components/parameters/ConversationId'
    get:
      tags: [Conversations]
      operationId: listMessages
      summary: List messages of a conversation
      description: Oldest first. `at` is TikTok's time when Hooka can read it, otherwise an estimate that can still change; the order of stored messages is reliable. A message Hooka reads later can be placed before messages already listed, so paging with `starting_after` from the last message you saw does not return it; list again or use `message.received`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/StartingAfter'
      responses:
        '200':
          description: A page of messages.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MessageList' }
              example:
                object: list
                data:
                  - id: msg_5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a
                    object: message
                    conversation_id: con_1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
                    direction: out
                    text: Hola Lucía, vi tu último vídeo y me encantó. ¿Te interesaría colaborar con nosotros?
                    at: 1790781240
                  - id: msg_6e5d4c3b-2a1f-4e0d-9c8b-7a6f5e4d3c2b
                    object: message
                    conversation_id: con_1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
                    direction: in
                    text: '¡Hola! Sí, cuéntame más'
                    at: 1790781500
                has_more: false
                next_cursor: null
        '400':
          description: '`invalid_request` (`limit`) or `invalid_cursor`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_limit: { $ref: '#/components/examples/ErrLimit' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/ConversationNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl "https://api.hooka.to/v1/conversations/con_1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/messages?limit=100" \
              -H "Authorization: Bearer $HOOKA_API_KEY"
    post:
      tags: [Conversations]
      operationId: sendReply
      summary: Send a reply
      description: |
        Queues a **real DM** from the conversation's sender. Its browser types it
        into the thread, usually within seconds.

        - The sender must be `connected`, otherwise 409 `sender_not_connected` and
          nothing is queued.
        - The workspace must not be paused: with the last invoice unpaid or the
          subscription ended, 403 `no_active_subscription` (see
          `subscription_status`) and nothing is queued.
        - **Fire and forget.** The `rpl_` id cannot be looked up, no event is
          emitted when the reply is sent or fails, and a queued reply never expires
          and cannot be cancelled. Once sent, it appears in
          `GET /conversations/{id}/messages` as a `direction: out` message.
        - Replies are not campaign sends: they create no target and do not count
          toward daily caps.
        - **Send an `Idempotency-Key`**: without one, a network retry sends a second
          DM. Not stored: `sender_not_connected`, `no_active_subscription`, 429 and 5xx.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReplyCreate' }
            example: { text: '¡Genial! Te paso los detalles por aquí: pagamos por vídeo y enviamos el producto.' }
      responses:
        '202':
          description: Reply queued.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReplyQueued' }
              example: { id: rpl_2b7c9d1e-3f4a-4b5c-8d6e-7f8a9b0c1d2e, object: message, status: queued }
        '400':
          description: '`invalid_request` on `text`, `invalid_json` or `invalid_idempotency_key`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                blank:
                  summary: Blank text
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: text must not be blank.
                      param: text
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/NoServiceOrTestKey' }
        '404': { $ref: '#/components/responses/ConversationNotFound' }
        '409':
          description: '`sender_not_connected`, `idempotency_key_reuse` or `idempotency_key_in_use`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                sender_not_connected:
                  summary: The conversation's sender is not connected
                  value:
                    error:
                      type: conflict_error
                      code: sender_not_connected
                      message: The sender of this conversation (snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10) is disconnected, so the reply cannot be sent. Reconnect it (POST /v1/senders/{id}/connection/refresh) and retry.
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                idempotency_key_reuse: { $ref: '#/components/examples/ErrIdempotencyKeyReuse' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/conversations/con_1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d/messages \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: reply-crm-4412-lucia-1" \
              -d '{"text":"¡Genial! Te paso los detalles por aquí: pagamos por vídeo y enviamos el producto."}'

  # ───────────────────────────── Webhook endpoints ─────────────────────────────
  /webhook_endpoints:
    post:
      tags: [Webhook endpoints]
      operationId: createWebhookEndpoint
      summary: Create a webhook endpoint
      description: |
        Registers an HTTPS URL to receive events. The response contains the signing
        `secret`, **shown only here** (an idempotent replay within 24 hours returns
        it again). There is no rotation: to rotate, create a new endpoint, switch
        over, then delete the old one. URLs are not deduplicated and there is no
        limit on the number of endpoints.

        **URL rules**, checked in order: 1–2048 characters; an absolute URL;
        `https://`; no username or password; not a private, loopback or reserved IP
        literal; a public domain name (not single-label, `localhost`, `*.local`,
        `*.internal`, `*.lan`, `*.localdomain` or `*.home.arpa`); the host resolves
        in DNS; and no resolved address is private. A temporary DNS failure is
        accepted.

        **Idempotency:** supported. Not stored: the two DNS-dependent
        `invalid_webhook_url` errors, 429 and 5xx.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEndpointCreate' }
            example:
              url: https://example.com/hooka/webhooks
              enabled_events: [target.sent, target.failed, target.replied, campaign.completed, sender.connection_failed]
      responses:
        '201':
          description: The endpoint, with its `secret`.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpointWithSecret' }
              example:
                id: we_5c0d8f4e-1b2a-4c3d-8e9f-0a1b2c3d4e5f
                object: webhook_endpoint
                url: https://example.com/hooka/webhooks
                enabled_events: [target.sent, target.failed, target.replied, campaign.completed, sender.connection_failed]
                status: enabled
                secret: whsec_2b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da5
                created: 1790781000
        '400':
          description: '`invalid_request` (`url` missing or not a string, bad `enabled_events`), `invalid_webhook_url`, `invalid_json` or `invalid_idempotency_key`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_event:
                  summary: Unknown event type
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: 'enabled_events[0] "target.*" is not an event type. Use one of: sender.connection.qr_generated, sender.connection.verification_required, sender.connected, sender.connection_failed, sender.status_changed, campaign.started, campaign.completed, target.sent, target.failed, target.skipped, target.not_delivered, target.replied, message.received; or "*" for every event.'
                      param: enabled_events.0
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                not_https:
                  summary: URL not https
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_webhook_url
                      message: url must use https:// (Hooka only delivers webhooks over HTTPS).
                      param: url
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                private_host:
                  summary: Host resolves to a private address
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_webhook_url
                      message: url host "hooks.internal-example.com" resolves to a private, loopback or reserved address. Webhook endpoints must be reachable on the public internet.
                      param: url
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/TestKeyReadOnly' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/webhook_endpoints \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: webhook-prod-1" \
              -d '{"url":"https://example.com/hooka/webhooks","enabled_events":["target.sent","target.failed","target.replied","campaign.completed","sender.connection_failed"]}'
    get:
      tags: [Webhook endpoints]
      operationId: listWebhookEndpoints
      summary: List webhook endpoints
      description: |
        Every endpoint of the workspace, enabled and disabled, without secrets.
        **Not paginated** and in no particular order: `limit` and `starting_after`
        are ignored. There is no `GET /webhook_endpoints/{id}`.
      responses:
        '200':
          description: All endpoints.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpointList' }
              example:
                object: list
                data:
                  - id: we_5c0d8f4e-1b2a-4c3d-8e9f-0a1b2c3d4e5f
                    object: webhook_endpoint
                    url: https://example.com/hooka/webhooks
                    enabled_events: [target.sent, target.failed, target.replied, campaign.completed, sender.connection_failed]
                    status: enabled
                    created: 1790781000
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/webhook_endpoints \
              -H "Authorization: Bearer $HOOKA_API_KEY"
  /webhook_endpoints/{id}:
    parameters:
      - $ref: '#/components/parameters/WebhookEndpointId'
    patch:
      tags: [Webhook endpoints]
      operationId: updateWebhookEndpoint
      summary: Update a webhook endpoint
      description: |
        Changes any of `url`, `enabled_events` and `status`; send at least one.

        - `status: disabled` immediately cancels every pending delivery of the
          endpoint, retries included. Re-enabling does not resend them.
        - A new `url` applies to pending deliveries too, on their next attempt; it
          is validated with the same rules as on create.
        - New `enabled_events` apply only to events emitted afterwards; deliveries
          already queued are still sent.

        The body is validated before the endpoint is looked up, so a bad body on an
        unknown id returns 400.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEndpointUpdate' }
            examples:
              disable:
                summary: Disable the endpoint
                value: { status: disabled }
              events:
                summary: Subscribe to every event
                value: { enabled_events: ['*'] }
      responses:
        '200':
          description: The updated endpoint (without `secret`).
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
              example:
                id: we_5c0d8f4e-1b2a-4c3d-8e9f-0a1b2c3d4e5f
                object: webhook_endpoint
                url: https://example.com/hooka/webhooks
                enabled_events: [target.sent, target.failed, target.replied, campaign.completed, sender.connection_failed]
                status: disabled
                created: 1790781000
        '400':
          description: '`invalid_request` (nothing to change, bad `status` or `enabled_events`, `url` not a string), `invalid_webhook_url` or `invalid_json`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                nothing_to_change:
                  summary: Empty body
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: Send at least one of url, enabled_events or status.
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                bad_status:
                  summary: Unknown status
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: status must be "enabled" or "disabled".
                      param: status
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/TestKeyReadOnly' }
        '404': { $ref: '#/components/responses/WebhookEndpointNotFound' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl -X PATCH https://api.hooka.to/v1/webhook_endpoints/we_5c0d8f4e-1b2a-4c3d-8e9f-0a1b2c3d4e5f \
              -H "Authorization: Bearer $HOOKA_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"status":"disabled"}'
    delete:
      tags: [Webhook endpoints]
      operationId: deleteWebhookEndpoint
      summary: Delete a webhook endpoint
      description: Deletes the endpoint and its pending deliveries. A request already in flight may still arrive. A second call returns 404.
      responses:
        '200':
          description: Deleted.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpointDeleted' }
              example: { id: we_5c0d8f4e-1b2a-4c3d-8e9f-0a1b2c3d4e5f, object: webhook_endpoint, deleted: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/TestKeyReadOnly' }
        '404': { $ref: '#/components/responses/WebhookEndpointNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl -X DELETE https://api.hooka.to/v1/webhook_endpoints/we_5c0d8f4e-1b2a-4c3d-8e9f-0a1b2c3d4e5f \
              -H "Authorization: Bearer $HOOKA_API_KEY"
  /webhook_endpoints/{id}/test:
    parameters:
      - $ref: '#/components/parameters/WebhookEndpointId'
    post:
      tags: [Webhook endpoints]
      operationId: testWebhookEndpoint
      summary: Send a test event
      description: |
        Stores a `ping` event and queues one delivery of it to **this** endpoint,
        whatever its `enabled_events`. The response is the stored event: its `id`
        is the `Hooka-Event-Id` you will receive. The ping is delivered, signed and
        retried like any other event, and appears in `GET /events`. This is the
        only synthetic event: there is no way to simulate a campaign's events.

        Send no body (it is not used). A non-empty body must still be JSON with
        `Content-Type: application/json` (otherwise 415), and it counts toward the
        `Idempotency-Key` match.

        **Idempotency:** supported; a replay does not queue a second ping. Not
        stored: `endpoint_disabled`, 429 and 5xx.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '202':
          description: The `ping` event, queued.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
            Idempotent-Replayed: { $ref: '#/components/headers/IdempotentReplayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Event' }
              example:
                id: evt_0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f
                object: event
                type: ping
                api_version: '2026-06-01'
                created: 1790781300
                workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
                data:
                  object: { message: This is a Hooka test event. }
        '400':
          description: '`invalid_json` (a body was sent and it is not valid JSON) or `invalid_idempotency_key`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                invalid_json: { $ref: '#/components/examples/ErrInvalidJson' }
                invalid_idempotency_key: { $ref: '#/components/examples/ErrInvalidIdempotencyKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/TestKeyReadOnly' }
        '404': { $ref: '#/components/responses/WebhookEndpointNotFound' }
        '409':
          description: '`endpoint_disabled` (nothing is queued), `idempotency_key_reuse` or `idempotency_key_in_use`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                endpoint_disabled:
                  summary: The endpoint is disabled
                  value:
                    error:
                      type: conflict_error
                      code: endpoint_disabled
                      message: 'This webhook endpoint is disabled, so nothing is delivered to it. Enable it (PATCH /v1/webhook_endpoints/{id} with {"status":"enabled"}) and retry.'
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                idempotency_key_reuse: { $ref: '#/components/examples/ErrIdempotencyKeyReuse' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '415': { $ref: '#/components/responses/UnsupportedMediaType' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl -X POST https://api.hooka.to/v1/webhook_endpoints/we_5c0d8f4e-1b2a-4c3d-8e9f-0a1b2c3d4e5f/test \
              -H "Authorization: Bearer $HOOKA_API_KEY"

  # ───────────────────────────── Events ─────────────────────────────
  /events:
    get:
      tags: [Events]
      operationId: listEvents
      summary: List events
      description: |
        Two modes:

        - **Polling** (`after=evt_…`): events created after the cursor, **oldest
          first**. Only events older than about 2 seconds are returned, so the
          cursor never moves past an event that is still being written. An unknown
          or expired `after` is a **404** `not_found`.
        - **Browsing** (no `after`): **newest first**, including the most recent
          events. `starting_after=evt_…` returns older events; an unknown one is a
          **400** `invalid_cursor`.

        Sending both `after` and `starting_after` is a 400.

        **Polling recipe**
        1. Take a cursor: `GET /events?limit=1` and keep `data[0].id`. If the
           workspace has no events yet there is no cursor; repeat until one exists.
        2. Loop `GET /events?after=<cursor>&limit=100`: process `data` in order, set
           the cursor to the last item's `id`, repeat immediately while `has_more`
           is true, otherwise wait.
        3. With a `type` filter, keep one cursor per type.

        A poller that falls more than 30 days behind loses its cursor (404) unless
        the cursor is the newest event of its type; take a new one with step 1.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - name: type
          in: query
          required: false
          description: 'Only events of this type. Anything else (an empty or repeated value, `target.*`, `campaign.failed`) is a 400 with `param: type`.'
          schema: { $ref: '#/components/schemas/EventType' }
          example: target.replied
        - name: after
          in: query
          required: false
          description: Polling mode. Returns events after this event (`evt_…` or a bare UUID), oldest first. Must be an event of this workspace, of any type.
          schema: { type: string }
          example: evt_6f1c2a4e-8a55-4c47-9a0e-3b1f0d2c9e11
        - name: starting_after
          in: query
          required: false
          description: Browsing mode only. Returns events older than this one, newest first.
          schema: { type: string }
      responses:
        '200':
          description: A page of events.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventList' }
              example:
                object: list
                data:
                  - id: evt_7a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
                    object: event
                    type: target.sent
                    api_version: '2026-06-01'
                    created: 1790781240
                    workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
                    data:
                      object:
                        id: tgt_9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d
                        object: target
                        campaign_id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                        username: creator.one
                        variables: { first_name: Lucía }
                        status: sent
                        step: 0
                        sent_at: 1790781240
                        error: null
                        error_message: null
                        delivery: { status: pending, checked_at: null, reason: null, detail: null }
                        sender_id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                has_more: false
                next_cursor: null
        '400':
          description: '`invalid_request` (`limit`, `type`, `after`, or both cursors) or `invalid_cursor` (`starting_after`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                bad_type:
                  summary: Unknown type
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: 'type must be one of: sender.connection.qr_generated, sender.connection.verification_required, sender.connected, sender.connection_failed, sender.status_changed, campaign.started, campaign.completed, target.sent, target.failed, target.skipped, target.not_delivered, target.replied, message.received, ping.'
                      param: type
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
                both_cursors:
                  summary: after and starting_after together
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: Use either after (events after it, oldest first) or starting_after (older events, newest first), not both.
                      param: starting_after
                      request_id: req_9eab538a53ad4f74840f1ee1927578d3
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/EventNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl (poll)
          source: |
            curl "https://api.hooka.to/v1/events?after=evt_6f1c2a4e-8a55-4c47-9a0e-3b1f0d2c9e11&limit=100" \
              -H "Authorization: Bearer $HOOKA_API_KEY"
        - lang: Shell
          label: curl (newest)
          source: |
            curl "https://api.hooka.to/v1/events?limit=1" \
              -H "Authorization: Bearer $HOOKA_API_KEY"
  /events/{id}:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      operationId: getEvent
      summary: Retrieve an event
      description: Any retained event, `ping` included. The object is identical to the webhook body.
      responses:
        '200':
          description: The event.
          headers:
            Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Event' }
              example:
                id: evt_0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f
                object: event
                type: ping
                api_version: '2026-06-01'
                created: 1790781300
                workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
                data:
                  object: { message: This is a Hooka test event. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/EventNotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
      x-codeSamples:
        - lang: Shell
          label: curl
          source: |
            curl https://api.hooka.to/v1/events/evt_0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f \
              -H "Authorization: Bearer $HOOKA_API_KEY"

# ═════════════════════════════ Webhook payloads ═════════════════════════════
webhooks:
  sender.connection.qr_generated:
    post:
      tags: [Webhook events]
      operationId: onSenderQrGenerated
      summary: sender.connection.qr_generated
      description: |
        A new QR is ready to scan for a sender that is still `awaiting_scan`,
        whether the attempt was started through the API or from the dashboard:
        the first QR of the attempt (once the sender's IP is ready), then each time
        Hooka reloads the QR page (about every 2 minutes while nobody scans, or
        after a verification window closes). A QR that changes in place updates
        `connection.qr_code` with no event: keep polling the sender for the latest
        QR. `data.object` is the sender, with the QR as a PNG data URI (often
        several KB).
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: sender.connection.qr_generated }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/Sender' }
            example:
              id: evt_1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e
              object: event
              type: sender.connection.qr_generated
              api_version: '2026-06-01'
              created: 1790760012
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object:
                  id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                  object: sender
                  platform: tiktok
                  username: null
                  label: 'Sales ES #1'
                  country: ES
                  status: awaiting_scan
                  status_reason: null
                  health: null
                  daily_limit: 30
                  sent_today: 0
                  connection:
                    status: requires_scan
                    qr_code: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
                    verification: null
                    websocket_url: wss://api.hooka.to/ws/sessions/0b6f7d7e-3f7e-4a0e-8a51-1f2e3d4c5b6a
                    error: null
                  exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                  created: 1790760000
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  sender.connection.verification_required:
    post:
      tags: [Webhook events]
      operationId: onSenderVerificationRequired
      summary: sender.connection.verification_required
      description: |
        After the scan, TikTok shows "Verify it's you", or the verification moves to
        a different step (`choose_method`, `enter_code` or `unsupported`). It does
        not fire for `finishing`, nor when the same step is updated (for example
        TikTok's error after a wrong code): poll `GET /senders/{id}` for those.
        Answer with `POST /senders/{id}/connection/verify`. Only for senders that
        are still `awaiting_scan`, whether the attempt was started through the API
        or from the dashboard.
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: sender.connection.verification_required }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/Sender' }
            example:
              id: evt_2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
              object: event
              type: sender.connection.verification_required
              api_version: '2026-06-01'
              created: 1790760090
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object:
                  id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                  object: sender
                  platform: tiktok
                  username: null
                  label: 'Sales ES #1'
                  country: ES
                  status: awaiting_scan
                  status_reason: null
                  health: null
                  daily_limit: 30
                  sent_today: 0
                  connection:
                    status: verification_required
                    qr_code: null
                    verification:
                      step: choose_method
                      title: Verify it's you
                      description: Choose a way to receive a verification code
                      options:
                        - { id: 0, kind: email, label: Email, detail: h***o@example.com }
                        - { id: 1, kind: phone, label: SMS, detail: '+34****3788' }
                      resend_available: false
                      resend_in: null
                      checking: false
                      messages: []
                    websocket_url: wss://api.hooka.to/ws/sessions/0b6f7d7e-3f7e-4a0e-8a51-1f2e3d4c5b6a
                    error: null
                  exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                  created: 1790760000
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  sender.connected:
    post:
      tags: [Webhook events]
      operationId: onSenderConnected
      summary: sender.connected
      description: |
        A login completed and the sender is `connected` (API or dashboard
        connections), or a sender recovered to `connected`. At most once per login.
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: sender.connected }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/Sender' }
            example:
              id: evt_3d4e5f6a-7b8c-4d9e-8f0a-2b3c4d5e6f7a
              object: event
              type: sender.connected
              api_version: '2026-06-01'
              created: 1790760140
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object:
                  id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                  object: sender
                  platform: tiktok
                  username: brand.es
                  label: 'Sales ES #1'
                  country: ES
                  status: connected
                  status_reason: null
                  health: 100
                  daily_limit: 30
                  sent_today: 0
                  connection: null
                  exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                  created: 1790760000
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  sender.connection_failed:
    post:
      tags: [Webhook events]
      operationId: onSenderConnectionFailed
      summary: sender.connection_failed
      description: |
        A connection attempt ended without success, whether it was started through
        the API or from the dashboard. The sender is now `disconnected` and
        `status_reason` (equal to `connection.error`) says why:
        `connection_expired`, `verification_timeout`, `verification_unsupported`,
        `superseded`, `connection_failed`, `disconnected_by_user`,
        `exit_ip_unavailable` (the sender's IP was not ready in time; Hooka keeps
        preparing it: poll the sender until `exit_ip.status` is `ready`, then
        refresh), `wrong_account` or `duplicate_account`. This is the only case where a
        non-`awaiting_scan` sender carries a `connection` object. Not emitted for
        `DELETE /senders/{id}`, nor for a sender's own attempt replaced by a refresh.
        Start a new attempt with `POST /senders/{id}/connection/refresh`.
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: sender.connection_failed }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/Sender' }
            example:
              id: evt_4e5f6a7b-8c9d-4e0f-9a1b-3c4d5e6f7a8b
              object: event
              type: sender.connection_failed
              api_version: '2026-06-01'
              created: 1790760330
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object:
                  id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                  object: sender
                  platform: tiktok
                  username: null
                  label: 'Sales ES #1'
                  country: ES
                  status: disconnected
                  status_reason: connection_expired
                  health: null
                  daily_limit: 30
                  sent_today: 0
                  connection:
                    status: expired
                    qr_code: null
                    verification: null
                    websocket_url: null
                    error: connection_expired
                  exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                  created: 1790760000
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  sender.status_changed:
    post:
      tags: [Webhook events]
      operationId: onSenderStatusChanged
      summary: sender.status_changed
      description: |
        A sender that was `connected`, `disconnected`, `cooldown` or `banned` moved
        to a different non-connected status: TikTok ended its session
        (`session_expired`, noticed at a send or inbox sync as well as when the
        sender starts), it was disconnected from the dashboard
        (`disconnected_by_user`), by Hooka support at the owner's request
        (`disconnected_by_support`) or with `DELETE /senders/{id}`
        (`disconnected_by_api`), or the workspace's subscription ended some time
        ago and Hooka released its IP (`subscription_inactive`); or Hooka paused it
        (`cooldown`) because TikTok flagged the account (`account_flagged`), it
        could not act on several targets in a row (`actions_blocked`), or because
        the workspace had more connected senders than seats (`plan_limit`; when a
        trial ends, also a more recent sender already paused for `account_flagged`
        or `actions_blocked`, with `previous_status` `cooldown`; after 7 days
        paused, `disconnected` with the same reason). `data.object` is
        the sender plus
        `previous_status` and `reason` (equal to `status_reason`). Running campaigns
        left with no connected sender are paused at the same time, with no event of
        their own; with `session_expired`, `account_flagged` or `actions_blocked`,
        the campaigns paused for it resume by themselves when the sender, or another
        sender of those campaigns, connects again. A sender that resumes by itself
        after a `plan_limit` pause counts too. With `plan_limit`, its campaigns
        resume by themselves as soon as one of their senders is connected again.
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: sender.status_changed }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/SenderStatusChangedPayload' }
            example:
              id: evt_5f6a7b8c-9d0e-4f1a-8b2c-4d5e6f7a8b9c
              object: event
              type: sender.status_changed
              api_version: '2026-06-01'
              created: 1790800000
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object:
                  id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                  object: sender
                  platform: tiktok
                  username: brand.es
                  label: 'Sales ES #1'
                  country: ES
                  status: disconnected
                  status_reason: session_expired
                  health: 0
                  daily_limit: 30
                  sent_today: 3
                  connection: null
                  exit_ip: { status: ready, country: ES, fallback: false, dedicated: true }
                  created: 1790760000
                  previous_status: connected
                  reason: session_expired
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  campaign.started:
    post:
      tags: [Webhook events]
      operationId: onCampaignStarted
      summary: campaign.started
      description: |
        A campaign became `running` from `draft` or from `completed`: `start`,
        `resume` of a completed campaign, targets added to a completed campaign, a
        follow-up becoming due in a completed campaign, or a dashboard start. Not
        emitted when resuming from `paused`. `data.object` is the campaign without
        `stats`.
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: campaign.started }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/Campaign' }
            example:
              id: evt_6a7b8c9d-0e1f-4a2b-9c3d-5e6f7a8b9c0d
              object: event
              type: campaign.started
              api_version: '2026-06-01'
              created: 1790763000
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object:
                  id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                  object: campaign
                  platform: tiktok
                  name: Creators ES - October
                  client_reference: crm-4412
                  status: running
                  message:
                    variants:
                      - Hola {{first_name}}, vi tu último vídeo y me encantó. ¿Te interesaría colaborar con nosotros?
                      - ¡Hola {{first_name}}! Nos gusta mucho tu contenido. ¿Hablamos de una colaboración?
                  follow_ups:
                    - wait_days: 3
                      variants:
                        - '{{first_name}}, ¿pudiste ver mi mensaje?'
                  settings:
                    rate_limit_seconds: 90
                    daily_limit: 40
                    daily_limit_per_sender: 40
                    dedupe_across_campaigns: true
                    on_missing_variable: skip
                    sender_ids: [snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10]
                  created: 1790762400
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  campaign.completed:
    post:
      tags: [Webhook events]
      operationId: onCampaignCompleted
      summary: campaign.completed
      description: |
        A `running` or `paused` campaign has no `pending` and no `processing`
        targets left. Emitted after the `target.*` event that closed it. It can fire
        again after the campaign is reopened: **completed does not mean the sequence
        is over** when follow-ups remain. Never emitted for drafts or archived
        campaigns. `data.object` is the campaign without `stats`.
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: campaign.completed }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/Campaign' }
            example:
              id: evt_7b8c9d0e-1f2a-4b3c-8d4e-6f7a8b9c0d1e
              object: event
              type: campaign.completed
              api_version: '2026-06-01'
              created: 1790900000
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object:
                  id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                  object: campaign
                  platform: tiktok
                  name: Creators ES - October
                  client_reference: crm-4412
                  status: completed
                  message:
                    variants:
                      - Hola {{first_name}}, vi tu último vídeo y me encantó. ¿Te interesaría colaborar con nosotros?
                      - ¡Hola {{first_name}}! Nos gusta mucho tu contenido. ¿Hablamos de una colaboración?
                  follow_ups:
                    - wait_days: 3
                      variants:
                        - '{{first_name}}, ¿pudiste ver mi mensaje?'
                  settings:
                    rate_limit_seconds: 90
                    daily_limit: 40
                    daily_limit_per_sender: 40
                    dedupe_across_campaigns: true
                    on_missing_variable: skip
                    sender_ids: [snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10]
                  created: 1790762400
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  target.sent:
    post:
      tags: [Webhook events]
      operationId: onTargetSent
      summary: target.sent
      description: |
        A DM (or follow-up step) was sent: see *What `sent` means*. `sender_id` is
        the account that sent it. A late correction of a target that had failed with
        `send_unconfirmed` emits it too, for the same target id. `delivery.status` is
        `pending` when Hooka scheduled its delivery check (about five minutes later; a
        message found missing then emits `target.not_delivered`).
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: target.sent }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/TargetEventPayload' }
            example:
              id: evt_7a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
              object: event
              type: target.sent
              api_version: '2026-06-01'
              created: 1790781240
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object:
                  id: tgt_9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d
                  object: target
                  campaign_id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                  username: creator.one
                  variables: { first_name: Lucía }
                  status: sent
                  step: 0
                  sent_at: 1790781240
                  error: null
                  error_message: null
                  delivery: { status: pending, checked_at: null, reason: null, detail: null }
                  sender_id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  target.failed:
    post:
      tags: [Webhook events]
      operationId: onTargetFailed
      summary: target.failed
      description: |
        A send failed (`error` is a failure code, `error_message` the detail, often
        TikTok's own notice), or a send hung for more than 15 minutes
        (`send_unconfirmed`, `error_message: stale_claim`). Rate-limited attempts do
        not emit it: they are retried. `sender_id` is the sending account, or `null`
        in rare cases for a hung send.
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: target.failed }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/TargetEventPayload' }
            example:
              id: evt_8c9d0e1f-2a3b-4c4d-9e5f-7a8b9c0d1e2f
              object: event
              type: target.failed
              api_version: '2026-06-01'
              created: 1790781400
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object:
                  id: tgt_7c2d4e6f-8a0b-4c1d-9e2f-3a4b5c6d7e8f
                  object: target
                  campaign_id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                  username: creator.two
                  variables: { first_name: Mia }
                  status: failed
                  step: 0
                  sent_at: null
                  error: recipient_privacy
                  error_message: No se puede enviar el mensaje debido a los ajustes de privacidad del usuario
                  delivery: null
                  sender_id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  target.skipped:
    post:
      tags: [Webhook events]
      operationId: onTargetSkipped
      summary: target.skipped
      description: |
        A target was resolved without sending. `reason` (equal to `error`) is one of
        `missing_variable`, `empty_message`, `duplicate_in_campaign`, `suppressed`,
        `duplicate_across_campaigns`, `recipient_replied`, `sender_unavailable`,
        `already_messaged` or `profile_not_found`.
        `sender_id` is the sender that checked it right before sending (for
        `already_messaged`, the one that opened the chat; for
        `profile_not_found`, the one that opened the profile), `null` when
        the skip came from adding a suppression (or an opt-out), or the removed
        sender for `sender_unavailable`. Items skipped when adding targets do not
        emit it.
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: target.skipped }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/TargetSkippedPayload' }
            examples:
              missing_variable:
                summary: Skipped before sending (missing variable)
                value:
                  id: evt_9d0e1f2a-3b4c-4d5e-8f6a-8b9c0d1e2f3a
                  object: event
                  type: target.skipped
                  api_version: '2026-06-01'
                  created: 1790790400
                  workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
                  data:
                    object:
                      id: tgt_2e3f4a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b
                      object: target
                      campaign_id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                      username: creator.three
                      variables: {}
                      status: skipped
                      step: 0
                      sent_at: null
                      error: missing_variable
                      error_message: first_name
                      delivery: null
                      sender_id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                      reason: missing_variable
              suppressed:
                summary: Skipped by a suppression (no sender involved)
                value:
                  id: evt_8e9f0a1b-2c3d-4e4f-8a5b-6c7d8e9f0a1b
                  object: event
                  type: target.skipped
                  api_version: '2026-06-01'
                  created: 1790790000
                  workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
                  data:
                    object:
                      id: tgt_5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e
                      object: target
                      campaign_id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                      username: creator.five
                      variables: { first_name: Nora }
                      status: skipped
                      step: 0
                      sent_at: null
                      error: suppressed
                      error_message: suppressed
                      delivery: null
                      sender_id: null
                      reason: suppressed
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  target.not_delivered:
    post:
      tags: [Webhook events]
      operationId: onTargetNotDelivered
      summary: target.not_delivered
      description: |
        About five minutes after `target.sent`, the sender reopened its chat with the
        person (without typing, on a fresh page load) and the message wasn't there, or
        TikTok marked it as not sent. The target keeps `status: sent` and keeps counting
        toward daily caps and the plan's volume; `delivery` (status `not_delivered`)
        says what was seen and `delivery.reason` why: `message_limit` (TikTok won't
        deliver more until the person replies or follows the sender; Hooka types
        nothing else to them from that sender until they write), `not_in_chat` or
        `marked_not_sent` (`delivery.detail` has TikTok's notice when there is one).
        At most once per send. A check that can't tell emits nothing (the target's
        `delivery.status` becomes `unverified`).
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: target.not_delivered }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/TargetEventPayload' }
            example:
              id: evt_1f2a3b4c-5d6e-4f7a-8b9c-0d1e2f3a4b5c
              object: event
              type: target.not_delivered
              api_version: '2026-06-01'
              created: 1790781550
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object:
                  id: tgt_9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d
                  object: target
                  campaign_id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                  username: creator.one
                  variables: { first_name: Lucía }
                  status: sent
                  step: 0
                  sent_at: 1790781240
                  error: null
                  error_message: null
                  delivery: { status: not_delivered, checked_at: 1790781550, reason: message_limit, detail: null }
                  sender_id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  target.replied:
    post:
      tags: [Webhook events]
      operationId: onTargetReplied
      summary: target.replied
      description: |
        The recipient replied after a DM of the campaign, in the conversation of the
        sender that wrote to them. It fires at most once per target, on the most
        recently sent target of that person, right after the `message.received` of
        the replying message. `status: replied` exists only in this payload: the
        stored target stays `sent`. `replied_at` is the reply's time (TikTok's when
        Hooka can read it, otherwise an estimate).
        Replies are detected when Hooka syncs the sender's inbox.
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: target.replied }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/TargetRepliedPayload' }
            example:
              id: evt_0e1f2a3b-4c5d-4e6f-9a7b-9c0d1e2f3a4b
              object: event
              type: target.replied
              api_version: '2026-06-01'
              created: 1790781520
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object:
                  id: tgt_9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d
                  object: target
                  campaign_id: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
                  username: creator.one
                  variables: { first_name: Lucía }
                  status: replied
                  step: 0
                  sent_at: 1790781240
                  error: null
                  error_message: null
                  delivery: null
                  sender_id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                  replied_at: 1790781500
                  conversation_id: con_1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
                  opt_out: false
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  message.received:
    post:
      tags: [Webhook events]
      operationId: onMessageReceived
      summary: message.received
      description: |
        A new inbound message was stored in any synced conversation of any sender,
        not only campaign replies. The first sync of a conversation emits its
        visible inbound history. `opt_out` is true when the text contains the whole
        word `stop`, `unsubscribe`, `baja` or `desuscribir`; the handle is then
        suppressed if it is known. `username` may be `null` until Hooka has opened
        the thread.
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: message.received }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/MessageReceivedPayload' }
            example:
              id: evt_1f2a3b4c-5d6e-4f7a-8b8c-0d1e2f3a4b5c
              object: event
              type: message.received
              api_version: '2026-06-01'
              created: 1790781520
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object:
                  id: msg_6e5d4c3b-2a1f-4e0d-9c8b-7a6f5e4d3c2b
                  object: message
                  conversation_id: con_1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
                  direction: in
                  text: '¡Hola! Sí, cuéntame más'
                  at: 1790781500
                  sender_id: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
                  username: creator.one
                  opt_out: false
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).
  ping:
    post:
      tags: [Webhook events]
      operationId: onPing
      summary: ping (test)
      description: |
        Sent only by `POST /webhook_endpoints/{id}/test` (or the dashboard's Test
        button) to that one endpoint. Not subscribable.
      security: []
      parameters:
        - $ref: '#/components/parameters/HookaSignature'
        - $ref: '#/components/parameters/HookaEventId'
        - $ref: '#/components/parameters/HookaEventType'
        - $ref: '#/components/parameters/WebhookUserAgent'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/Event'
                - type: object
                  properties:
                    type: { const: ping }
                    data:
                      type: object
                      properties:
                        object: { $ref: '#/components/schemas/PingPayload' }
            example:
              id: evt_0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f
              object: event
              type: ping
              api_version: '2026-06-01'
              created: 1790781300
              workspace_id: ws_3f1c2a9e-7b4d-4c1e-9a0b-5d6e7f8a9b0c
              data:
                object: { message: This is a Hooka test event. }
      responses:
        '200':
          description: Any 2xx status within 10 seconds acknowledges the delivery.
        default:
          description: Any other status, a redirect, a timeout or a network error is a failure and is retried (up to 8 attempts over about 56.6 hours).

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <token>` with an `hk_live_…` key (read and write), an
        `hk_test_…` key (read-only) or an `mcp_at_…` OAuth access token. Create keys
        in the dashboard's Developers page or with `POST /api_keys`.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Makes this POST safe to retry. 1–255 characters, scoped to your workspace,
        kept 24 hours. Same key and same request → the stored response is replayed
        with `Idempotent-Replayed: true`; same key and a different request → 409
        `idempotency_key_reuse`; first request still running → 409
        `idempotency_key_in_use`. 5xx, 429 and the state errors listed on the
        operation are not stored.
      schema: { type: string, minLength: 1, maxLength: 255 }
      example: create-campaign-crm-4412
    Limit:
      name: limit
      in: query
      required: false
      description: Page size, 1–100, digits only. Out of range is a 400 (never clamped).
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    StartingAfter:
      name: starting_after
      in: query
      required: false
      description: Cursor. The `next_cursor` of the previous page (the id of its last item). Must be an item of this same list, otherwise 400 `invalid_cursor`.
      schema: { type: string }
    SenderId:
      name: id
      in: path
      required: true
      description: Sender id (`snd_…`, or the bare UUID).
      schema: { type: string }
      example: snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10
    CampaignId:
      name: id
      in: path
      required: true
      description: Campaign id (`cmp_…`, or the bare UUID).
      schema: { type: string }
      example: cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41
    ConversationId:
      name: id
      in: path
      required: true
      description: Conversation id (`con_…`, or the bare UUID).
      schema: { type: string }
      example: con_1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
    WebhookEndpointId:
      name: id
      in: path
      required: true
      description: Webhook endpoint id (`we_…`, or the bare UUID).
      schema: { type: string }
      example: we_5c0d8f4e-1b2a-4c3d-8e9f-0a1b2c3d4e5f
    EventId:
      name: id
      in: path
      required: true
      description: Event id (`evt_…`, or the bare UUID).
      schema: { type: string }
      example: evt_0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f
    ApiKeyId:
      name: id
      in: path
      required: true
      description: API key id (`key_…`, or the bare UUID).
      schema: { type: string }
      example: key_6a1e3b5c-7d9f-4b2a-8c4e-6f8a0b2c4d6e
    HookaSignature:
      name: Hooka-Signature
      in: header
      required: true
      description: |
        `t=<unix seconds of this attempt>,v1=<hex HMAC-SHA256>`. The HMAC key is
        the endpoint's full secret (including `whsec_`); the message is
        `t + "." + raw body`.
      schema: { type: string }
      example: t=1790781241,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
    HookaEventId:
      name: Hooka-Event-Id
      in: header
      required: true
      description: The event `id`. Stable across retries; deduplicate on it.
      schema: { type: string }
      example: evt_7a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
    HookaEventType:
      name: Hooka-Event-Type
      in: header
      required: true
      description: The event `type`.
      schema: { type: string }
      example: target.sent
    WebhookUserAgent:
      name: User-Agent
      in: header
      required: true
      schema: { type: string, const: Hooka-Webhooks/1.0 }

  headers:
    HookaRequestId:
      description: Id of this request (`req_` + 32 hex), also in `error.request_id`. Quote it to support.
      schema: { type: string, pattern: '^req_[0-9a-f]{32}$' }
      example: req_9eab538a53ad4f74840f1ee1927578d3
    IdempotentReplayed:
      description: Present, with the value `true`, only when this response is a stored replay of an earlier request with the same `Idempotency-Key`.
      schema: { type: string, const: 'true' }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer, minimum: 1 }
      example: 37
    RateLimitLimit:
      description: Requests allowed per one-minute window for the workspace (300).
      schema: { type: integer }
      example: 300
    RateLimitRemaining:
      description: Requests left in the current window.
      schema: { type: integer, minimum: 0 }
      example: 0
    RateLimitReset:
      description: Seconds until the current window resets.
      schema: { type: integer, minimum: 0 }
      example: 37

  responses:
    Unauthorized:
      description: '`missing_api_key` (no `Authorization: Bearer` header) or `invalid_api_key` (unknown or revoked key, or an expired OAuth token).'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missing_api_key:
              summary: No credential
              value:
                error:
                  type: authentication_error
                  code: missing_api_key
                  message: 'Provide your API key as: Authorization: Bearer hk_live_...'
                  request_id: req_9eab538a53ad4f74840f1ee1927578d3
            invalid_api_key:
              summary: Unknown or revoked key
              value:
                error:
                  type: authentication_error
                  code: invalid_api_key
                  message: Invalid or revoked API key.
                  request_id: req_9eab538a53ad4f74840f1ee1927578d3
    TestKeyReadOnly:
      description: '`test_key_read_only`: `hk_test_` keys cannot write.'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            test_key_read_only: { $ref: '#/components/examples/ErrTestKeyReadOnly' }
    NoServiceOrTestKey:
      description: '`no_active_subscription` (the workspace has no service: the last invoice is unpaid or the subscription ended; see `subscription_status`) or `test_key_read_only` (`hk_test_` keys cannot write).'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            no_active_subscription_unpaid: { $ref: '#/components/examples/ErrNoServiceUnpaid' }
            no_active_subscription_ended: { $ref: '#/components/examples/ErrNoServiceEnded' }
            test_key_read_only: { $ref: '#/components/examples/ErrTestKeyReadOnly' }
    IdempotencyConflict:
      description: '`idempotency_key_reuse` (the key was used for a different request in the last 24 hours) or `idempotency_key_in_use` (the first request is still running; retry with the same key).'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            idempotency_key_reuse: { $ref: '#/components/examples/ErrIdempotencyKeyReuse' }
            idempotency_key_in_use: { $ref: '#/components/examples/ErrIdempotencyKeyInUse' }
    LifecycleBadRequest:
      description: '`no_sender_available` (`Cannot <start|resume> this campaign: none of its senders is connected. …`; not stored by idempotency), `invalid_json` (a body was sent and it is not valid JSON), or `invalid_idempotency_key`.'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            no_sender_available:
              summary: No connected sender in the campaign
              value:
                error:
                  type: invalid_request_error
                  code: no_sender_available
                  message: 'Cannot start this campaign: none of its senders is connected. Reconnect one (POST /v1/senders/{id}/connection/refresh) and retry.'
                  request_id: req_9eab538a53ad4f74840f1ee1927578d3
            invalid_json: { $ref: '#/components/examples/ErrInvalidJson' }
            invalid_idempotency_key: { $ref: '#/components/examples/ErrInvalidIdempotencyKey' }
    LifecycleConflict:
      description: '`invalid_campaign_state` (`Cannot <start|pause|resume> a campaign that is <status>.`: the action is not allowed from the current status; not stored by idempotency), `idempotency_key_reuse` or `idempotency_key_in_use`.'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            invalid_campaign_state:
              summary: Action not allowed from the current status
              value:
                error:
                  type: conflict_error
                  code: invalid_campaign_state
                  message: Cannot start a campaign that is completed.
                  request_id: req_9eab538a53ad4f74840f1ee1927578d3
            idempotency_key_reuse: { $ref: '#/components/examples/ErrIdempotencyKeyReuse' }
            idempotency_key_in_use: { $ref: '#/components/examples/ErrIdempotencyKeyInUse' }
    SenderNotFound:
      description: '`not_found`: no such sender in this workspace (or a malformed id).'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: { type: not_found_error, code: not_found, message: No such sender., request_id: req_9eab538a53ad4f74840f1ee1927578d3 }
    CampaignNotFound:
      description: '`not_found`: no such campaign in this workspace (or a malformed id).'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: { type: not_found_error, code: not_found, message: No such campaign., request_id: req_9eab538a53ad4f74840f1ee1927578d3 }
    ConversationNotFound:
      description: '`not_found`: no such conversation in this workspace (or a malformed id).'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: { type: not_found_error, code: not_found, message: No such conversation., request_id: req_9eab538a53ad4f74840f1ee1927578d3 }
    WebhookEndpointNotFound:
      description: '`not_found`: no such webhook endpoint in this workspace (or a malformed id).'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: { type: not_found_error, code: not_found, message: No such endpoint., request_id: req_9eab538a53ad4f74840f1ee1927578d3 }
    EventNotFound:
      description: '`not_found`: no such event in this workspace, or it was deleted by retention. On `GET /events` this is the answer to an unknown `after` cursor.'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: { type: not_found_error, code: not_found, message: No such event., request_id: req_9eab538a53ad4f74840f1ee1927578d3 }
    PayloadTooLarge:
      description: '`body_too_large`: the body is over 1 MiB.'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: { type: invalid_request_error, code: body_too_large, message: Request body is too large (limit 1 MB)., request_id: req_9eab538a53ad4f74840f1ee1927578d3 }
    UnsupportedMediaType:
      description: '`unsupported_content_type`: a non-empty body sent without `Content-Type: application/json`.'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              type: invalid_request_error
              code: unsupported_content_type
              message: 'Send the body as JSON with the header Content-Type: application/json.'
              request_id: req_9eab538a53ad4f74840f1ee1927578d3
    RateLimited:
      description: '`rate_limited`: over 300 requests in the current one-minute window of the workspace.'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        x-ratelimit-limit: { $ref: '#/components/headers/RateLimitLimit' }
        x-ratelimit-remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        x-ratelimit-reset: { $ref: '#/components/headers/RateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            rate_limited: { $ref: '#/components/examples/ErrRateLimited' }
    InternalError:
      description: '`internal_error`. Safe to retry; a retry with the same `Idempotency-Key` runs again.'
      headers:
        Hooka-Request-Id: { $ref: '#/components/headers/HookaRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              type: api_error
              code: internal_error
              message: Something went wrong on our side. Retry later; if it persists contact support with the request_id.
              request_id: req_9eab538a53ad4f74840f1ee1927578d3

  examples:
    ErrInvalidJson:
      summary: Malformed JSON
      value:
        error: { type: invalid_request_error, code: invalid_json, message: Request body is not valid JSON., request_id: req_9eab538a53ad4f74840f1ee1927578d3 }
    ErrInvalidIdempotencyKey:
      summary: Idempotency-Key empty or too long
      value:
        error: { type: invalid_request_error, code: invalid_idempotency_key, message: Idempotency-Key must be between 1 and 255 characters., request_id: req_9eab538a53ad4f74840f1ee1927578d3 }
    ErrLimit:
      summary: limit out of range
      value:
        error: { type: invalid_request_error, code: invalid_request, message: limit must be an integer between 1 and 100., param: limit, request_id: req_9eab538a53ad4f74840f1ee1927578d3 }
    ErrPlatformNotSupported:
      summary: Platform not available
      value:
        error:
          type: invalid_request_error
          code: platform_not_supported
          message: Platform "instagram" is not available yet. Only "tiktok" is supported today.
          param: platform
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrTestKeyReadOnly:
      summary: Write with a test key
      value:
        error:
          type: permission_error
          code: test_key_read_only
          message: 'Test keys (hk_test_) are read-only: Hooka has no sandbox, so any write would act on your real TikTok accounts. Use an hk_live_ key to create, start or send.'
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrOauthTokenNotAllowed:
      summary: OAuth token on an API key route
      value:
        error:
          type: permission_error
          code: oauth_token_not_allowed
          message: OAuth access tokens cannot list, create or revoke API keys. Manage API keys from the Hooka dashboard or with an hk_live_ API key.
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrNoActiveSubscription:
      summary: No active subscription
      value:
        error:
          type: permission_error
          code: no_active_subscription
          message: This workspace has no active subscription, so it has no sender seats. Subscribe to a plan in the Hooka dashboard to connect senders.
          subscription_status: none
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrNoServiceUnpaid:
      summary: Last invoice unpaid
      value:
        error:
          type: permission_error
          code: no_active_subscription
          message: "This workspace's last invoice is unpaid, so Hooka is paused: nothing is sent, no sender works and senders cannot be connected until the payment method is updated in the Hooka dashboard (Billing). Retry after paying."
          subscription_status: unpaid
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrNoServiceEnded:
      summary: Subscription ended
      value:
        error:
          type: permission_error
          code: no_active_subscription
          message: "This workspace's subscription has ended, so Hooka is paused: nothing is sent, no sender works and senders cannot be connected. Subscribe again in the Hooka dashboard (Billing), then retry."
          subscription_status: ended
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrSenderLimitReached:
      summary: Every seat is taken
      value:
        error:
          type: permission_error
          code: sender_limit_reached
          message: Your plan allows 3 sender(s). Upgrade to add more.
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrIdempotencyKeyReuse:
      summary: Key reused for a different request
      value:
        error:
          type: conflict_error
          code: idempotency_key_reuse
          message: This Idempotency-Key was already used for a different request (another endpoint or body) in the last 24 hours. Use a new key for a new request.
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrIdempotencyKeyInUse:
      summary: First request still running
      value:
        error:
          type: conflict_error
          code: idempotency_key_in_use
          message: A request with this Idempotency-Key is still being processed; retry in a few seconds.
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrInvalidCountry:
      summary: Not an ISO 3166-1 alpha-2 country
      value:
        error:
          type: invalid_request_error
          code: invalid_request
          message: country must be an ISO 3166-1 alpha-2 country code such as "ES".
          param: country
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrCountryRequired:
      summary: No country and no default country
      value:
        error:
          type: invalid_request_error
          code: country_required
          message: 'Pass country (ISO 3166-1 alpha-2): this workspace has no confirmed default country. Each sender gets a dedicated residential IP in its own country.'
          param: country
          suggested_country: ES
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrCountryLocked:
      summary: A sender's country cannot change
      value:
        error:
          type: invalid_request_error
          code: invalid_request
          message: A sender's country cannot be changed after it is created. Contact Hooka support to move it to another country.
          param: country
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrExitIpCoolingDown:
      summary: No IP in that country right now
      value:
        error:
          type: conflict_error
          code: exit_ip_not_ready
          message: There is no dedicated IP available right now in NL nor in its nearest countries (checked less than 15 minutes ago). We have been alerted. Retry in 840 s.
          retry_after: 840
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrExitIpUnavailable:
      summary: No IP provider active
      value:
        error:
          type: conflict_error
          code: exit_ip_not_ready
          message: We cannot provision a dedicated exit IP for ES right now (no IP provider is active). Connecting without one would tie this sender to our server IP, which TikTok penalises. Retry later.
          retry_after: null
          request_id: req_9eab538a53ad4f74840f1ee1927578d3
    ErrRateLimited:
      summary: Workspace rate limit
      value:
        error:
          type: rate_limit_error
          code: rate_limited
          message: Too many requests. Retry in 37 s.
          retry_after: 37
          request_id: req_9eab538a53ad4f74840f1ee1927578d3

  schemas:
    # ─────────── Errors ───────────
    Error:
      type: object
      description: The error envelope of every error response.
      required: [error]
      properties:
        error:
          type: object
          required: [type, code, message, request_id]
          properties:
            type: { $ref: '#/components/schemas/ErrorType' }
            code: { $ref: '#/components/schemas/ErrorCode' }
            message:
              type: string
              description: Human-readable English explanation. It may be reworded; do not parse it.
            param:
              type: string
              description: The field (dotted path, e.g. `settings.sender_ids`, `targets.3`) or query parameter at fault. Present only when one is at fault.
            retry_after:
              type: [integer, 'null']
              minimum: 1
              description: Seconds to wait. Present on 429 responses, and on 409 `exit_ip_not_ready`, where it is `null` when there is no estimate (no IP provider active).
            suggested_country:
              type: [string, 'null']
              description: Present only on 400 `country_required`. The country Hooka suggests for the sender (ISO 3166-1 alpha-2), or `null`.
              examples: [ES]
            subscription_status:
              type: string
              enum: [unpaid, ended, none]
              description: |
                Present only on 403 `no_active_subscription`. Why the workspace has no
                service: `unpaid` (the last invoice is unpaid: Hooka is paused until it
                is paid), `ended` (the subscription ended) or `none` (it never had
                one). The same value as `subscription.status` on `GET /workspace`.
            request_id:
              type: string
              pattern: '^req_[0-9a-f]{32}$'
              description: Equal to the `Hooka-Request-Id` header. A replayed stored error keeps the original request's id.
    ErrorType:
      type: string
      description: Error category. It determines the HTTP status.
      enum:
        - invalid_request_error
        - authentication_error
        - permission_error
        - not_found_error
        - conflict_error
        - rate_limit_error
        - api_error
    ErrorCode:
      type: string
      description: |
        Stable, machine-readable error code. If you meet a code that is not listed
        here, handle it according to `type`.

        | Code | Status | When |
        |---|---|---|
        | `invalid_request` | 400 | A body field or query parameter is invalid (see `param`). |
        | `invalid_json` | 400 | The body is not valid JSON. |
        | `invalid_idempotency_key` | 400 | `Idempotency-Key` is empty or longer than 255 characters. |
        | `invalid_cursor` | 400 | `starting_after` is not an item of this list. |
        | `invalid_webhook_url` | 400 | The webhook URL breaks a URL rule. |
        | `platform_not_supported` | 400 | `platform` is not `tiktok`. |
        | `country_required` | 400 | No `country` for the sender and no confirmed default country (see `suggested_country`). |
        | `unsupported_setting` | 400 | `settings.stop_on_reply: false`. |
        | `sender_invalid` | 400 | A `settings.sender_ids` item is not a valid id. |
        | `sender_not_found` | 400 | A `settings.sender_ids` item is not a sender of this workspace. |
        | `no_sender_available` | 400 | No connected sender to create, start or resume the campaign. |
        | `FST_ERR_CTP_INVALID_CONTENT_LENGTH` | 400 | The body does not match its `Content-Length`. |
        | `body_too_large` | 413 | The body is over 1 MiB. |
        | `unsupported_content_type` | 415 | A non-empty body without `Content-Type: application/json`. |
        | `missing_api_key` | 401 | No usable `Authorization: Bearer` header. |
        | `invalid_api_key` | 401 | Unknown or revoked key, or an invalid or expired OAuth token. |
        | `test_key_read_only` | 403 | A write with an `hk_test_` key. |
        | `oauth_token_not_allowed` | 403 | An OAuth token on an `/api_keys` route. |
        | `no_active_subscription` | 403 | Connecting or refreshing a sender without service (`subscription_status` `unpaid`, `ended` or `none`); starting or resuming a campaign, or replying to a conversation, while the last invoice is unpaid or the subscription ended (`unpaid`, `ended`). |
        | `sender_limit_reached` | 403 | Connecting a sender with every seat taken. |
        | `route_not_found` | 404 | Unknown path or method. |
        | `not_found` | 404 | The resource does not exist in this workspace. |
        | `idempotency_key_reuse` | 409 | The key was used for a different request. |
        | `idempotency_key_in_use` | 409 | The first request with this key is still running. |
        | `invalid_campaign_state` | 409 | The campaign action is not allowed from its status. |
        | `already_connected` | 409 | Refreshing a connected sender. |
        | `exit_ip_not_ready` | 409 | No dedicated IP available right now in the sender's country or its nearest ones (see `retry_after`), or no IP provider active. |
        | `not_verifying` | 409 | The sender is not waiting for a verification step. |
        | `verification_action_unavailable` | 409 | The verification action is not available at this step. |
        | `sender_not_connected` | 409 | Replying through a sender that is not connected. |
        | `endpoint_disabled` | 409 | Testing a disabled webhook endpoint. |
        | `rate_limited` | 429 | Workspace rate limit exceeded. |
        | `too_many_actions` | 429 | Too many verification actions queued. |
        | `internal_error` | 500 | Unexpected error on Hooka's side. |
      enum:
        - invalid_request
        - invalid_json
        - invalid_idempotency_key
        - invalid_cursor
        - invalid_webhook_url
        - platform_not_supported
        - country_required
        - unsupported_setting
        - sender_invalid
        - sender_not_found
        - no_sender_available
        - FST_ERR_CTP_INVALID_CONTENT_LENGTH
        - body_too_large
        - unsupported_content_type
        - missing_api_key
        - invalid_api_key
        - test_key_read_only
        - oauth_token_not_allowed
        - no_active_subscription
        - sender_limit_reached
        - route_not_found
        - not_found
        - idempotency_key_reuse
        - idempotency_key_in_use
        - invalid_campaign_state
        - already_connected
        - exit_ip_not_ready
        - not_verifying
        - verification_action_unavailable
        - sender_not_connected
        - endpoint_disabled
        - rate_limited
        - too_many_actions
        - internal_error

    # ─────────── Shared ───────────
    Platform:
      type: string
      description: Only `tiktok` is available today.
      enum: [tiktok]
      default: tiktok
    Timestamp:
      type: integer
      description: Unix time in seconds, UTC.
      examples: [1790762400]

    # ─────────── Workspace ───────────
    Workspace:
      type: object
      required: [id, object, name, created, exit_ip, seats, subscription]
      properties:
        id: { type: string, pattern: '^ws_[0-9a-f-]{36}$' }
        object: { type: string, const: workspace }
        name: { type: string, description: '"Default" in practice; there is no API to rename it.' }
        created: { $ref: '#/components/schemas/Timestamp' }
        exit_ip:
          type: object
          required: [status, region, suggested_region, per_sender]
          description: The workspace's default country for new senders. The IP itself is per sender (`exit_ip` on the sender).
          properties:
            status:
              type: string
              enum: [ready, no_region, unavailable, provisioning]
              description: |
                `ready`: there is a confirmed default country and Hooka can
                provision IPs. `no_region`: no confirmed default country; it does not
                block, pass `country` on `POST /senders`. `unavailable`: Hooka has no
                IP provider active right now. `provisioning` is a former value that is
                no longer returned.
            region:
              type: [string, 'null']
              description: The confirmed default country (ISO 3166-1 alpha-2), used by `POST /senders` without `country`; `null` if none.
              examples: [ES]
            suggested_region:
              type: [string, 'null']
              description: Without a confirmed default country, the one Hooka suggests (from the billing details); `null` otherwise.
            per_sender:
              type: boolean
              const: true
              description: Each sender has its own dedicated IP.
        seats:
          type: object
          required: [limit, used, trial]
          properties:
            limit:
              type: [integer, 'null']
              minimum: 0
              description: Senders allowed by the plan, including trial seats while a trial lasts. `0` without an active subscription; `null` means unlimited.
            used:
              type: integer
              minimum: 0
              description: "Senders that hold a seat: not `disconnected`, and not paused over the plan's seats (`cooldown` with `plan_limit`)."
            trial:
              type: [object, 'null']
              required: [extra, ends_at]
              description: Extra seats Hooka granted for a limited time (a trial), already included in `limit`. `null` without one. When it ends, senders over `limit` are paused (`cooldown`, `plan_limit`).
              properties:
                extra:
                  type: integer
                  minimum: 1
                  description: Trial seats included in `limit`.
                ends_at:
                  $ref: '#/components/schemas/Timestamp'
        subscription:
          type: object
          required: [status, ends_at, disconnects_at]
          description: |
            Whether Hooka works for this workspace. Without service (`unpaid` or
            `ended`) nothing that acts on TikTok works: no campaign sends or
            follow-ups, no inbox sync, no replies, no comment replies, and senders
            cannot be connected or refreshed; those calls return 403
            `no_active_subscription`. Reading (campaigns, targets, conversations,
            events) keeps working.
          properties:
            status:
              type: string
              enum: [active, unpaid, ended, none]
              description: |
                `active`: paid (or in trial). `unpaid`: the last invoice is unpaid;
                Hooka is paused until it is paid, with no grace period, and senders
                stay connected meanwhile. `ended`: the subscription ended.
                `none`: it never had one (no seats, so senders cannot be
                connected; campaigns can start but send nothing).
            ends_at:
              oneOf:
                - { $ref: '#/components/schemas/Timestamp' }
                - { type: 'null' }
              description: |
                With `active`: when the subscription ends if a cancellation is
                scheduled (`null` if it renews). With `ended`: when it ended (`null`
                while unknown).
            disconnects_at:
              oneOf:
                - { $ref: '#/components/schemas/Timestamp' }
                - { type: 'null' }
              description: |
                With `ended`: when Hooka disconnects the workspace's senders and
                releases their dedicated IPs (7 days after `ends_at`; they get
                `status_reason: subscription_inactive`). `null` otherwise.

    # ─────────── API keys ───────────
    ApiKeyCreate:
      type: object
      properties:
        mode:
          type: string
          enum: [live, test]
          default: live
          description: '`test` keys are read-only.'
        label:
          type: string
          maxLength: 80
          description: Your own name for the key. `null` is rejected; omit it instead.
    ApiKeyCreated:
      type: object
      required: [id, object, mode, prefix, secret]
      properties:
        id: { type: string, pattern: '^key_[0-9a-f-]{36}$' }
        object: { type: string, const: api_key }
        mode: { type: string, enum: [live, test] }
        prefix: { type: string, description: 'The first 14 characters of the secret, e.g. `hk_live_1a2b3c`. Shown in the list to recognise the key.' }
        secret:
          type: string
          pattern: '^hk_(live|test)_[0-9a-f]{48}$'
          description: The full key. Shown only in this response.
    ApiKey:
      type: object
      required: [id, object, mode, prefix, label, revoked, last_used, created]
      properties:
        id: { type: string, pattern: '^key_[0-9a-f-]{36}$' }
        object: { type: string, const: api_key }
        mode: { type: string, enum: [live, test] }
        prefix: { type: string }
        label: { type: [string, 'null'] }
        revoked: { type: boolean }
        last_used:
          type: [integer, 'null']
          description: Approximate (updated at most once a minute) Unix time of the last request with this key. OAuth tokens do not update it.
        created: { $ref: '#/components/schemas/Timestamp' }
    ApiKeyList:
      type: object
      required: [object, data]
      description: Not paginated.
      properties:
        object: { type: string, const: list }
        data: { type: array, items: { $ref: '#/components/schemas/ApiKey' } }
    ApiKeyRevoked:
      type: object
      required: [id, object, revoked]
      properties:
        id: { type: string, pattern: '^key_[0-9a-f-]{36}$' }
        object: { type: string, const: api_key }
        revoked: { type: boolean, const: true }

    # ─────────── Senders ───────────
    SenderStatus:
      type: string
      enum: [awaiting_scan, connected, disconnected, cooldown, banned]
      description: |
        `awaiting_scan`: a connection attempt is in progress. `connected`: can send.
        `disconnected`: not logged in (see `status_reason`). `cooldown`: Hooka
        paused sending from it (`status_reason` `account_flagged` or
        `actions_blocked`: refresh to reconnect it; or `plan_limit`: more
        connected senders than seats, it resumes by itself once a seat is free).
        `banned` is reserved: no current code path produces it.
    SenderStatusReason:
      type: [string, 'null']
      description: |
        Why the sender is not connected. Always `null` while `connected` or
        `awaiting_scan`, and `null` when there is no stable reason.

        | Code | Meaning |
        |---|---|
        | `session_expired` | TikTok ended the account's session: Hooka saw TikTok's login prompt when the sender started, at a send or during an inbox sync. Reconnect it (if it was paused for `plan_limit`, once a seat is free); the campaigns paused for this resume by themselves. |
        | `disconnected_by_api` | `DELETE /senders/{id}`. |
        | `disconnected_by_user` | Disconnected from the Hooka dashboard. |
        | `disconnected_by_support` | Disconnected by Hooka support at the owner's request (to move it to another country or give it another IP). |
        | `connection_expired` | The attempt ran out of time without a login: the QR was not scanned (or not confirmed on the phone) within about 300 s, or the attempt was lost. |
        | `verification_timeout` | TikTok's "Verify it's you" was not completed in time. |
        | `verification_unsupported` | TikTok asked for something Hooka cannot handle (captcha, password…). |
        | `superseded` | Another connection attempt in the workspace cancelled this one. |
        | `connection_failed` | Any other failure of the attempt (browser or TikTok page error). |
        | `exit_ip_unavailable` | The sender's dedicated IP was not ready in time. Hooka keeps preparing it: poll until `exit_ip.status` is `ready`, then refresh. |
        | `wrong_account` | The QR was scanned with a TikTok account other than the sender's `username`. The sender is unchanged. |
        | `duplicate_account` | The TikTok account scanned is already connected in another sender of the workspace. A dashboard "Add account" attempt for an account that already is a sender in the same country is merged instead: the attempt's sender gets this code and is then deleted. |
        | `subscription_inactive` | The workspace's subscription ended: the sender was disconnected and its IP released. |
        | `account_flagged` | The sender is `cooldown`: TikTok showed the account its account-status warning (the account may be at risk; change the password). Check it in the TikTok app, then refresh. |
        | `actions_blocked` | The sender is `cooldown`: it could not act on 4 targets in a row, or found no Message button on 6 profiles in a row. Check it in the TikTok app, then refresh (if several senders were paused at once, Hooka support resumes them). |
        | `plan_limit` | The sender is `cooldown`: the workspace had more connected senders than `seats.limit` (a trial of extra seats ended, or the plan's seats went down) and Hooka paused the most recent ones (when a trial ends, also a more recent sender that was paused for `account_flagged` or `actions_blocked`); it resumes by itself when a seat is free (the owner adds seats in the Hooka dashboard, Billing → Manage subscription, or another sender is disconnected). With `disconnected`: it stayed paused 7 days, so Hooka disconnected it and released its IP; refresh it once a seat is free. |
      enum:
        - session_expired
        - disconnected_by_api
        - disconnected_by_user
        - connection_expired
        - verification_timeout
        - verification_unsupported
        - superseded
        - connection_failed
        - exit_ip_unavailable
        - wrong_account
        - duplicate_account
        - subscription_inactive
        - disconnected_by_support
        - account_flagged
        - actions_blocked
        - plan_limit
        - null
    Sender:
      type: object
      required: [id, object, platform, username, label, country, status, status_reason, health, daily_limit, sent_today, connection, exit_ip, created]
      properties:
        id: { type: string, pattern: '^snd_[0-9a-f-]{36}$' }
        object: { type: string, const: sender }
        platform: { type: string, const: tiktok }
        username:
          type: [string, 'null']
          description: TikTok handle, without `@`, as read at the last successful connection. `null` until the first successful connection, and also `null` if Hooka could not read the handle when the sender connected.
        label:
          type: [string, 'null']
          description: Your own label (up to 80 characters).
        country:
          type: [string, 'null']
          description: The TikTok account's country (ISO 3166-1 alpha-2), set at creation. The sender's dedicated IP is from this country. It cannot be changed through the API. `null` only for a sender connected before per-sender IPs, until its next refresh.
          examples: [ES]
        status: { $ref: '#/components/schemas/SenderStatus' }
        status_reason: { $ref: '#/components/schemas/SenderStatusReason' }
        health:
          type: [integer, 'null']
          minimum: 0
          maximum: 100
          description: 'Coarse stored value, not a live score. `null` until the first successful connection; `100` when a connection succeeds; `0` after the sender is disconnected by `DELETE /senders/{id}`, from the dashboard, or because its session expired. A connection attempt that fails leaves it unchanged, so a sender that never connected can be `disconnected` with `health: null`.'
        daily_limit:
          type: integer
          description: The sender's own daily DM cap across all its campaigns (default 30). Configured value; change it in the dashboard. Warm-up, when enabled in the dashboard, lowers the effective cap and is not shown here.
        sent_today:
          type: integer
          minimum: 0
          description: Successful DMs from this sender since midnight UTC. Always `0` while `awaiting_scan`. Daily caps count attempts, so a sender can stop for the day with `sent_today` below `daily_limit`.
        connection:
          description: The live connection attempt while `status` is `awaiting_scan`; `null` otherwise (except in `sender.connection_failed`).
          oneOf:
            - $ref: '#/components/schemas/Connection'
            - type: 'null'
        exit_ip: { $ref: '#/components/schemas/SenderExitIp' }
        created: { $ref: '#/components/schemas/Timestamp' }
    SenderExitIp:
      type: object
      required: [status, country, fallback, dedicated]
      description: |
        The sender's own dedicated residential IP, in its `country`: it logs in,
        sends and reads replies through it, and no other sender or Hooka customer
        uses it. The IP address itself is never returned. When the plan has no
        seat for both, a new sender in the same country as a disconnected sender
        takes over that sender's IP instead of getting a new one. If the new
        sender never connects, the IP goes back to the old one.
      properties:
        status:
          type: string
          enum: [ready, provisioning, unavailable, none]
          description: |
            `ready`: assigned. `provisioning`: Hooka is preparing it (usually under a
            minute; a few minutes when it buys one); a connection attempt waits for
            it with `connection.status: pending`. `unavailable`: preparing it
            failed, or the IP stopped working; the next refresh gets a new one.
            `none`: no IP yet; one is prepared at the next connection attempt.
        country:
          type: [string, 'null']
          description: The country the IP is really in. `null` without an IP, and while Hooka has not verified it yet.
          examples: [ES]
        fallback:
          type: boolean
          description: '`true` when no IP was available in the sender''s `country` and Hooka assigned one from the nearest country. Only `true` when both countries are known.'
        dedicated:
          type: boolean
          description: '`false` only for a sender connected before per-sender IPs that still shares the workspace''s former IP with other senders; it gets its own IP at its next refresh.'
      example: { status: ready, country: ES, fallback: false, dedicated: true }
    Connection:
      type: object
      required: [status, qr_code, verification, websocket_url, error]
      properties:
        status:
          type: string
          enum: [pending, requires_scan, scanned, verification_required, connected, expired, failed]
          description: |
            | Value | Meaning |
            |---|---|
            | `pending` | Starting, or TikTok is loading a new QR. Do not scan an old QR; wait. |
            | `requires_scan` | `qr_code` holds a QR to scan. |
            | `scanned` | TikTok saw the scan; the owner must confirm on the phone. |
            | `verification_required` | TikTok asks "Verify it's you": see `verification`. |
            | `connected` | Rarely seen: the sender itself turns `connected` and `connection` becomes `null`. |
            | `expired` / `failed` | The attempt ended; see `error`. |
        qr_code:
          type: [string, 'null']
          description: PNG image as a `data:image/png;base64,…` URI, only while `status` is `requires_scan`. Always use the latest one; do not cache it.
        verification:
          description: TikTok's verification screen, only while `status` is `verification_required`.
          oneOf:
            - $ref: '#/components/schemas/Verification'
            - type: 'null'
        websocket_url:
          type: [string, 'null']
          description: |
            Optional live feed of the attempt (`wss://api.hooka.to/ws/sessions/<uuid>`),
            unauthenticated, receive-only, with an internal and unversioned frame
            format. `null` once the attempt ended. Prefer `GET /senders/{id}` and
            webhooks.
        error:
          description: Why the attempt ended; `null` while it is alive.
          type: [string, 'null']
          enum: [connection_expired, verification_timeout, verification_unsupported, superseded, connection_failed, disconnected_by_user, exit_ip_unavailable, wrong_account, duplicate_account, null]
    Verification:
      type: object
      required: [step, title, description, options, resend_available, resend_in, checking, messages]
      description: TikTok's texts come in the language of the sender's country.
      properties:
        step:
          type: string
          enum: [choose_method, enter_code, finishing, unsupported]
          description: '`choose_method`: pick where the code goes. `enter_code`: submit the code. `finishing`: do nothing. `unsupported`: Hooka cannot complete it.'
        title: { type: [string, 'null'], description: 'TikTok''s heading (up to 300 characters, possibly empty). `null` for `finishing`.' }
        description: { type: [string, 'null'], description: 'TikTok''s sub-text (up to 300 characters). `null` for `unsupported` and `finishing`.' }
        options:
          type: array
          maxItems: 6
          description: Only filled in `choose_method`; `[]` otherwise. Ids can skip numbers; send one back exactly as listed.
          items: { $ref: '#/components/schemas/VerificationOption' }
        resend_available: { type: boolean, description: TikTok's "resend code" button is enabled. }
        resend_in:
          type: [integer, 'null']
          description: Seconds left on TikTok's resend countdown when Hooka last read the screen. It does not count down between reads.
        checking: { type: boolean, description: TikTok is checking a submitted code. }
        messages:
          type: array
          maxItems: 5
          items: { type: string }
          description: Other texts of TikTok's window, such as its error after a wrong code.
    VerificationOption:
      type: object
      required: [id, kind, label, detail]
      properties:
        id: { type: integer, minimum: 0 }
        kind: { type: string, enum: [email, phone] }
        label: { type: string, description: TikTok's label for the method. }
        detail: { type: string, description: 'Masked address or number, e.g. `+34****3788`.' }
    SenderList:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object: { type: string, const: list }
        data: { type: array, items: { $ref: '#/components/schemas/Sender' } }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'], description: Id of the last item when `has_more` is true; `null` otherwise. }
    SenderCreate:
      type: object
      properties:
        platform: { $ref: '#/components/schemas/Platform' }
        label:
          type: [string, 'null']
          maxLength: 80
          description: Your own label, trimmed; at most 80 characters after trimming. An empty string becomes `null`.
        country:
          type: string
          pattern: '^[A-Za-z]{2}$'
          description: 'The TikTok account''s country, an assigned ISO 3166-1 alpha-2 code (case-insensitive; `UK` is stored as `GB`). The sender gets its own dedicated residential IP there. Default: the workspace''s confirmed default country; without one, 400 `country_required`. It cannot be changed later.'
          examples: [ES]
    SenderRefresh:
      type: object
      properties:
        country:
          type: string
          pattern: '^[A-Za-z]{2}$'
          description: Only for a sender that has no country yet (connected before per-sender IPs). The same country as the sender's is ignored; a different one is a 400.
    SenderUpdate:
      type: object
      required: [label]
      additionalProperties: false
      properties:
        label:
          type: [string, 'null']
          maxLength: 80
          description: New label (trimmed, at most 80 characters). `null` or `""` clears it.
    SenderDisconnected:
      type: object
      required: [id, object, deleted, status]
      properties:
        id: { type: string, pattern: '^snd_[0-9a-f-]{36}$' }
        object: { type: string, const: sender }
        deleted: { type: boolean, const: true, description: 'The sender was disconnected. It is **not** removed: it stays readable and reconnectable.' }
        status: { type: string, const: disconnected }
    VerifyAction:
      description: One of three actions, selected by `action`.
      oneOf:
        - $ref: '#/components/schemas/VerifyChooseMethod'
        - $ref: '#/components/schemas/VerifySubmitCode'
        - $ref: '#/components/schemas/VerifyResendCode'
      discriminator:
        propertyName: action
        mapping:
          choose_method: '#/components/schemas/VerifyChooseMethod'
          submit_code: '#/components/schemas/VerifySubmitCode'
          resend_code: '#/components/schemas/VerifyResendCode'
    VerifyChooseMethod:
      type: object
      required: [action, option_id]
      properties:
        action: { type: string, const: choose_method }
        option_id:
          type: integer
          minimum: 0
          description: An `id` from the current `connection.verification.options` (a JSON number, not a string).
    VerifySubmitCode:
      type: object
      required: [action, code]
      properties:
        action: { type: string, const: submit_code }
        code:
          type: string
          pattern: '^\s*(?:[A-Za-z0-9]\s*){4,12}$'
          description: The code TikTok sent. Whitespace is removed; then it must be 4–12 letters or digits. Only the first 8 characters are typed into TikTok.
    VerifyResendCode:
      type: object
      required: [action]
      properties:
        action: { type: string, const: resend_code }

    # ─────────── Campaigns ───────────
    CampaignStatus:
      type: string
      enum: [draft, running, paused, completed, archived]
      description: '`archived` is set only from the dashboard; archived campaigns are excluded from `GET /campaigns`.'
    MessageOutput:
      type: object
      required: [variants]
      properties:
        variants:
          type: array
          items: { type: string }
          description: Always this shape, even when the campaign was created with a plain string.
    FollowUp:
      type: object
      required: [wait_days, variants]
      properties:
        wait_days:
          type: integer
          description: Days to wait after the **previous** step was sent.
        variants:
          type: array
          items: { type: string }
    FollowUpInput:
      type: object
      required: [wait_days, variants]
      properties:
        wait_days:
          type: integer
          minimum: 1
          maximum: 60
          description: Days to wait after the **previous** step was sent. (`delay_days` is not accepted.)
        variants:
          type: array
          minItems: 1
          maxItems: 20
          items: { type: string, minLength: 1, maxLength: 2000 }
          description: 1–20 variants; one is picked at random for each target.
    CampaignSettings:
      type: object
      required: [rate_limit_seconds, daily_limit, daily_limit_per_sender, dedupe_across_campaigns, on_missing_variable, sender_ids]
      properties:
        rate_limit_seconds:
          type: integer
          description: Minimum seconds between two DMs of the same sender (settable range 30–3600). Not used when the campaign has a dashboard send window; see *Pacing and daily caps*.
        daily_limit:
          type: [integer, 'null']
          description: Maximum DMs per day for this campaign, all its senders combined (settable range 1–500). `null` means no campaign cap (each sender's own cap still applies).
        daily_limit_per_sender:
          type: [integer, 'null']
          deprecated: true
          description: Deprecated alias with the same value as `daily_limit`. Despite its name it is **not** per sender.
        dedupe_across_campaigns: { type: boolean }
        on_missing_variable: { type: string, enum: [blank, skip] }
        sender_ids:
          type: array
          items: { type: string, pattern: '^snd_[0-9a-f-]{36}$' }
          description: The campaign's current senders, in any status, ordered by sender creation (oldest first). Reflects sender changes made in the dashboard; the API cannot change it after creation.
    CampaignStats:
      type: object
      required: [total, queued, pending, processing, sent, replied, failed, skipped, reply_rate, not_delivered]
      description: |
        `total`, `pending`, `processing`, `sent`, `failed` and `skipped` count target
        rows, and each follow-up step is its own row. `replied` counts people.
      properties:
        total: { type: integer, minimum: 0, description: All target rows, follow-ups included. }
        queued: { type: integer, minimum: 0, deprecated: true, description: Old name of `pending`. }
        pending: { type: integer, minimum: 0 }
        processing: { type: integer, minimum: 0 }
        sent: { type: integer, minimum: 0, description: Sent rows, follow-ups included. }
        replied: { type: integer, minimum: 0, description: Distinct people this campaign messaged who then replied in a conversation of one of its senders. }
        failed: { type: integer, minimum: 0 }
        skipped: { type: integer, minimum: 0 }
        reply_rate:
          type: number
          minimum: 0
          maximum: 1
          description: '`replied` ÷ distinct people contacted, rounded to 3 decimals; `0` before anyone was contacted. Do not compute it from `sent`.'
        not_delivered:
          type: integer
          minimum: 0
          description: Rows among `sent` whose delivery check found the message missing or marked as not sent (`delivery.status` `not_delivered`). Still counted in `sent`.
    Campaign:
      type: object
      required: [id, object, platform, name, client_reference, status, message, follow_ups, settings, created]
      properties:
        id: { type: string, pattern: '^cmp_[0-9a-f-]{36}$' }
        object: { type: string, const: campaign }
        platform: { type: string, const: tiktok }
        name: { type: string }
        client_reference:
          type: [string, 'null']
          description: Your own reference. Not copied to targets or `target.*` events.
        status: { $ref: '#/components/schemas/CampaignStatus' }
        message: { $ref: '#/components/schemas/MessageOutput' }
        follow_ups:
          type: array
          items: { $ref: '#/components/schemas/FollowUp' }
        settings: { $ref: '#/components/schemas/CampaignSettings' }
        stats:
          $ref: '#/components/schemas/CampaignStats'
          description: Only in `GET /campaigns/{id}` and the `start`/`pause`/`resume` responses.
        created: { $ref: '#/components/schemas/Timestamp' }
    CampaignWithStats:
      allOf:
        - $ref: '#/components/schemas/Campaign'
        - type: object
          required: [stats]
    CampaignCreated:
      allOf:
        - $ref: '#/components/schemas/Campaign'
        - type: object
          required: [targets_accepted, target_ids, targets_skipped]
          properties:
            targets_accepted: { type: integer, minimum: 0 }
            target_ids:
              type: array
              items: { type: string, pattern: '^tgt_[0-9a-f-]{36}$' }
              description: Ids of the accepted inline targets, in request order.
            targets_skipped:
              type: array
              items: { $ref: '#/components/schemas/SkippedTarget' }
    CampaignList:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          description: Campaigns without `stats`.
          items: { $ref: '#/components/schemas/Campaign' }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'], description: Id of the last item when `has_more` is true; `null` otherwise. }
    MessageInput:
      description: A single message, or 1–20 variants picked at random for each target. (A top-level `variants` field is not accepted.)
      oneOf:
        - type: string
          minLength: 1
          maxLength: 2000
        - type: object
          required: [variants]
          properties:
            variants:
              type: array
              minItems: 1
              maxItems: 20
              items: { type: string, minLength: 1, maxLength: 2000 }
    CampaignSettingsInput:
      type: object
      properties:
        sender_ids:
          type: array
          minItems: 1
          maxItems: 10
          items: { type: string }
          description: Senders to use (`snd_…` or bare UUIDs), in any status; duplicates are ignored. Without it, the 10 oldest connected senders of the workspace are used.
        rate_limit_seconds:
          type: integer
          minimum: 30
          maximum: 3600
          default: 60
          description: Minimum seconds between two DMs of the same sender. (`spacing_seconds` is not a field.)
        daily_limit:
          type: [integer, 'null']
          minimum: 1
          maximum: 500
          description: Maximum DMs per day for this campaign, all its senders combined. Omit or send `null` for no campaign cap.
        daily_limit_per_sender:
          type: [integer, 'null']
          minimum: 1
          maximum: 500
          deprecated: true
          description: Deprecated alias of `daily_limit`, with the same campaign-wide meaning. Used only when `daily_limit` is absent; different values of both are a 400.
        dedupe_across_campaigns:
          type: boolean
          default: true
          description: Skip people another campaign of the workspace already sent a DM to (or tried to, without being able to confirm it went out), both when adding targets and right before each send.
        on_missing_variable:
          type: string
          enum: [blank, skip]
          default: blank
          description: '`blank` sends the message with the missing variable empty; `skip` skips the target (`missing_variable`).'
        stop_on_reply:
          type: boolean
          const: true
          description: Compatibility only. Follow-ups always stop on reply; `true` has no effect and `false` is a 400 `unsupported_setting`.
        sender_pool:
          type: boolean
          description: 'Compatibility only, never stored. `true` is accepted only without `sender_ids`; `false` only with them. Otherwise 400.'
    CampaignCreate:
      type: object
      required: [name, message]
      properties:
        platform: { $ref: '#/components/schemas/Platform' }
        name:
          type: string
          minLength: 1
          maxLength: 120
        client_reference:
          type: string
          maxLength: 200
          description: Your own reference (for example a CRM id). Returned on the campaign and on `campaign.*` events.
        message: { $ref: '#/components/schemas/MessageInput' }
        follow_ups:
          type: array
          maxItems: 10
          description: Up to 10 follow-up steps, sent in order.
          items: { $ref: '#/components/schemas/FollowUpInput' }
        targets:
          type: array
          maxItems: 1000
          description: Optional inline targets, inserted with the campaign. They never start it.
          items: { $ref: '#/components/schemas/TargetInput' }
        settings: { $ref: '#/components/schemas/CampaignSettingsInput' }

    # ─────────── Targets ───────────
    TargetInput:
      description: A username string, or an object with the username and its template variables.
      oneOf:
        - type: string
          minLength: 1
          description: The username. A leading `@`, surrounding spaces and upper case are fine.
          examples: [creator.one]
        - type: object
          anyOf:
            - required: [username]
            - required: [handle]
          properties:
            username: { type: string, minLength: 1 }
            handle:
              type: string
              minLength: 1
              description: Accepted alias of `username`; `username` wins when both are present.
            variables:
              type: object
              description: Values for `{{key}}` placeholders. Numbers and booleans are stored as text.
              additionalProperties:
                type: [string, number, boolean]
    TargetsAdd:
      type: object
      required: [targets]
      properties:
        targets:
          type: array
          minItems: 1
          maxItems: 1000
          items: { $ref: '#/components/schemas/TargetInput' }
    SkippedTarget:
      type: object
      required: [username, reason]
      properties:
        username: { type: string, description: The item exactly as you sent it, before normalization. }
        reason:
          type: string
          enum: [invalid, duplicate_in_campaign, suppressed, duplicate_across_campaigns]
          description: |
            `invalid`: not a valid handle after normalization. `duplicate_in_campaign`:
            earlier in this batch, or already in this campaign in any status (also
            when this campaign already sent this person a DM whose target no longer exists).
            `suppressed`: on the Do-Not-Contact list. `duplicate_across_campaigns`:
            another campaign of the workspace already sent this person a DM, or a
            send it could not confirm (only with `dedupe_across_campaigns`).
    TargetsAdded:
      type: object
      required: [accepted, target_ids, skipped, campaign_status, resumed]
      properties:
        accepted: { type: integer, minimum: 0 }
        target_ids:
          type: array
          items: { type: string, pattern: '^tgt_[0-9a-f-]{36}$' }
          description: Ids of the accepted targets, in request order.
        skipped:
          type: array
          items: { $ref: '#/components/schemas/SkippedTarget' }
        campaign_status: { $ref: '#/components/schemas/CampaignStatus' }
        resumed:
          type: boolean
          description: '`true` only when this call moved a completed campaign back to `running`.'
        resume_blocked_reason:
          type: string
          enum: [no_connected_sender, no_active_subscription]
          description: |
            Present only when the campaign stays `completed` although targets were added.
            `no_connected_sender`: none of its senders is connected; reconnect one, then
            call `start` or `resume`. `no_active_subscription`: the workspace has no service
            (the last invoice is unpaid or the subscription ended, see `GET /v1/workspace`
            `subscription.status`); once it is paid, call `start` or `resume`. New values
            may be added.
    TargetStatus:
      type: string
      enum: [pending, processing, sent, failed, skipped]
    TargetErrorCode:
      type: [string, 'null']
      description: |
        Stable code when a target failed or was skipped (`null` otherwise; a
        `pending` target can show `rate_limited` or `send_failed` between attempts).

        **Failures** (`status: failed`, `target.failed`):

        | Code | Meaning |
        |---|---|
        | `blocked_by_platform` | TikTok accepted the text but did not deliver it and flagged it in the chat (possible community-guidelines issue). `error_message` is TikTok's notice. Resending the same text is blocked again: change the copy. |
        | `recipient_unreachable` | No chat could be opened: no Message button (private account, DMs from strangers off, the sender is blocked, or a missing profile that couldn't be confirmed gone; a confirmed one is skipped with `profile_not_found`). |
        | `recipient_privacy` | TikTok said the message cannot be sent because of the recipient's privacy settings. `error_message` is TikTok's notice. |
        | `message_limit` | TikTok won't take more messages to this person from this sender until they reply or follow the sender (typically a second message to someone who hasn't replied): it accepts the text, shows a notice and doesn't deliver it. `error_message` is TikTok's notice, which says the limit is with this person or lasts until they reply or follow back. Never retried; until the person replies, later messages from this sender to them fail with this code without being typed (`error_message` ends with `(not attempted: …)`). |
        | `rate_limited` | TikTok throttled the sender: it said to slow down or try again later, or showed a message-limit notice that doesn't say it's about this person. Retried automatically after a pause: in practice seen on `pending` targets between attempts. |
        | `send_unconfirmed` | The send could not be confirmed (the chat did not accept the text, or the send hung for 15 minutes). The DM may or may not have gone out. Never retried, except a hung send whose sender reports later that its session had ended before anything was typed: it goes back to `pending`. |
        | `sender_session_expired` | Kept for compatibility: a send that finds the sender's session ended no longer fails. The target goes back to `pending` (nothing was typed) and the sender becomes `disconnected` with `session_expired`. |
        | `send_failed` | Any other failure, including TikTok's "not delivered" notices without a reason and a message that disappeared from the chat with no notice (`error_message` starts with `vanished`). Details in `error_message`. |

        **Skips** (`status: skipped`, `target.skipped`):

        | Code | Meaning |
        |---|---|
        | `missing_variable` | A `{{variable}}` is missing and `on_missing_variable` is `skip`. `error_message` lists the keys. |
        | `empty_message` | The message rendered empty. |
        | `duplicate_in_campaign` | The same person already has a sent, in-flight or unconfirmed (`send_unconfirmed`) row for this step. |
        | `suppressed` | The handle is on the Do-Not-Contact list. |
        | `duplicate_across_campaigns` | Another campaign of the workspace already sent this person a DM (or a send it could not confirm). |
        | `recipient_replied` | Follow-up cancelled: the person replied to an earlier DM of the campaign. |
        | `sender_unavailable` | Follow-up cancelled: the only sender allowed to send it was removed from the campaign or deleted. |
        | `already_messaged` | First DM not typed: the sender's chat with this person already held a message from that sender that went out, and the person has never written there. `error_message` is that message (first 200 characters). Doesn't count toward daily caps, unless the sender followed the person on that visit (dashboard-only "follow before DM"). |
        | `profile_not_found` | The TikTok account doesn't exist (deleted, renamed or removed by TikTok, or the username is wrong): TikTok said so for this username with the sender's session, after a reload and without a session. Nothing was followed or typed, at any step. `error_message` is the evidence (for example `TikTok 10221 (logged in and logged out): "Couldn't find this account"`). Doesn't count toward daily caps; retrying failed targets doesn't requeue it. |
      enum:
        - blocked_by_platform
        - recipient_unreachable
        - recipient_privacy
        - message_limit
        - rate_limited
        - send_unconfirmed
        - sender_session_expired
        - send_failed
        - missing_variable
        - empty_message
        - duplicate_in_campaign
        - suppressed
        - duplicate_across_campaigns
        - recipient_replied
        - sender_unavailable
        - already_messaged
        - profile_not_found
        - null
    TargetSkipReason:
      type: string
      enum: [missing_variable, empty_message, duplicate_in_campaign, suppressed, duplicate_across_campaigns, recipient_replied, sender_unavailable, already_messaged, profile_not_found]
    DeliveryStatus:
      type: string
      enum: [pending, verified, not_delivered, unverified]
    DeliveryReason:
      type: [string, 'null']
      description: |
        Why a target's delivery check ended `not_delivered` or `unverified` (`null`
        while `pending` and for `verified`).

        | Code | Status | Meaning |
        |---|---|---|
        | `message_limit` | `not_delivered` | Missing or marked as not sent, and the chat already held a message from this sender that the person never answered (or TikTok showed its limit notice for this person). Until the person writes, Hooka types nothing else to them from that sender (later messages fail with `message_limit` and `(not attempted: …)`). |
        | `not_in_chat` | `not_delivered` | The message isn't in the chat. |
        | `marked_not_sent` | `not_delivered` | In the chat but marked as not sent: red "!" icon, no TikTok id, or a "not sent" notice (`detail`). |
        | `sender_offline` | `unverified` | The sender was disconnected or not running for the hour after the send. |
        | `no_time` | `unverified` | The sender never had a free moment between sends within the hour. |
        | `chat_unavailable` | `unverified` | The chat couldn't be opened again. |
        | `inconclusive` | `unverified` | The chat opened, but what it showed didn't allow a decision (an empty chat once, a username that is neither the sender's nor the person's…). |
      enum:
        - message_limit
        - not_in_chat
        - marked_not_sent
        - sender_offline
        - no_time
        - chat_unavailable
        - inconclusive
        - null
    TargetDelivery:
      type: [object, 'null']
      description: |
        The delivery check: about five minutes after a send, the sender reopens the chat
        (without typing, on a fresh page load) and looks for the message by its TikTok
        id. `null` unless `status` is `sent`, and for sends from before the check
        existed. It never changes the target's `status`; a `not_delivered` one emits
        `target.not_delivered` and still counts toward caps.
      required: [status, checked_at, reason, detail]
      properties:
        status: { $ref: '#/components/schemas/DeliveryStatus' }
        checked_at:
          type: [integer, 'null']
          description: When the check finished (Unix seconds). `null` while `pending`.
        reason: { $ref: '#/components/schemas/DeliveryReason' }
        detail:
          type: [string, 'null']
          description: TikTok's own notice next to the message, when there was one. Free text.
    Target:
      type: object
      required: [id, object, campaign_id, username, variables, status, step, sent_at, error, error_message, delivery]
      properties:
        id: { type: string, pattern: '^tgt_[0-9a-f-]{36}$' }
        object: { type: string, const: target }
        campaign_id: { type: string, pattern: '^cmp_[0-9a-f-]{36}$' }
        username: { type: string, description: The stored handle (normalized for targets added through the API). }
        variables:
          type: object
          additionalProperties: { type: string }
          description: The target's variables, as strings. `{}` when none. Follow-ups inherit them.
        status: { $ref: '#/components/schemas/TargetStatus' }
        step:
          type: integer
          minimum: 0
          description: 0 for the initial DM, N for follow-up N.
        sent_at:
          type: [integer, 'null']
          description: When Hooka recorded the send (Unix seconds). `null` unless `status` is `sent`.
        error: { $ref: '#/components/schemas/TargetErrorCode' }
        error_message:
          type: [string, 'null']
          description: Free-form detail, often TikTok's own notice in the sender's UI language. May change; do not parse it.
        delivery: { $ref: '#/components/schemas/TargetDelivery' }
    TargetList:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object: { type: string, const: list }
        data: { type: array, items: { $ref: '#/components/schemas/Target' } }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'], description: Id of the last item when `has_more` is true; `null` otherwise. }

    # ─────────── Suppressions ───────────
    SuppressionsAdd:
      type: object
      required: [usernames]
      properties:
        usernames:
          type: array
          minItems: 1
          maxItems: 1000
          items: { type: string }
        reason:
          type: string
          maxLength: 120
          default: manual
          description: Stored only for newly added handles. `null` is rejected.
    SuppressionBatch:
      type: object
      required: [object, added, already_present, invalid]
      properties:
        object: { type: string, const: suppression_batch }
        added: { type: integer, minimum: 0, description: Handles newly added. }
        already_present: { type: integer, minimum: 0, description: Valid distinct handles that were already on the list. }
        invalid:
          type: array
          items: { type: string }
          description: Inputs that are not valid handles, exactly as sent. They were not stored.
    Suppression:
      type: object
      required: [id, object, username, reason, created]
      properties:
        id: { type: string, pattern: '^sup_[0-9a-f-]{36}$' }
        object: { type: string, const: suppression }
        username: { type: string }
        reason:
          type: string
          description: '`manual` (default), `opt_out` (added automatically), or the free text given when adding it.'
        created: { $ref: '#/components/schemas/Timestamp' }
    SuppressionList:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object: { type: string, const: list }
        data: { type: array, items: { $ref: '#/components/schemas/Suppression' } }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'], description: Id of the last item when `has_more` is true; `null` otherwise. }
    SuppressionDeleted:
      type: object
      required: [username, object, deleted]
      properties:
        username: { type: string, description: The normalized handle that was removed. }
        object: { type: string, const: suppression }
        deleted: { type: boolean, const: true }

    # ─────────── Conversations ───────────
    ConversationStatus:
      type: string
      enum: [open, interested, not_interested, archived]
      description: Set in the dashboard; read-only through the API.
    Conversation:
      type: object
      required: [id, object, sender_id, username, status, unread_count, last_message, created]
      properties:
        id: { type: string, pattern: '^con_[0-9a-f-]{36}$' }
        object: { type: string, const: conversation }
        sender_id:
          type: string
          pattern: '^snd_[0-9a-f-]{36}$'
          description: The sender (your TikTok account) that owns the thread.
        username:
          type: [string, 'null']
          description: The other person's handle as Hooka stored it. `null` until Hooka has opened the thread.
        status: { $ref: '#/components/schemas/ConversationStatus' }
        unread_count:
          type: integer
          minimum: 0
          description: Inbound messages that reached Hooka since the conversation was last opened in the Hooka dashboard inbox and that no outbound message answers yet (an outbound message answers the inbound messages before it that Hooka already had when it stored it), or `0`. It goes by when Hooka read each message, not by its `at`, so a message written just before your reply went out but read by Hooka after it still counts. API reads never mark it read.
        last_message:
          description: Preview of the latest message, `null` when there is none.
          oneOf:
            - type: object
              required: [direction, text, at]
              properties:
                direction: { type: [string, 'null'], enum: [in, out, null] }
                text: { type: string }
                at: { type: [integer, 'null'], description: 'Unix time of the latest activity: TikTok''s (from the inbox list, sometimes only the day, then the start of that day in the sender''s time zone, or from the latest message) or the exact time of a message sent through Hooka. An estimate only when TikTok shows no time.' }
            - type: 'null'
        created: { $ref: '#/components/schemas/Timestamp' }
    ConversationList:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object: { type: string, const: list }
        data: { type: array, items: { $ref: '#/components/schemas/Conversation' } }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'], description: Id of the last item when `has_more` is true; `null` otherwise. }
    Message:
      type: object
      required: [id, object, conversation_id, direction, text, at]
      properties:
        id: { type: string, pattern: '^msg_[0-9a-f-]{36}$' }
        object: { type: string, const: message }
        conversation_id: { type: string, pattern: '^con_[0-9a-f-]{36}$' }
        direction:
          type: string
          enum: [in, out]
          description: '`in` is from the other person; `out` is from your sender, including messages typed by hand in TikTok.'
        text: { type: string }
        at:
          type: integer
          description: Unix time of the message as TikTok shows it, when Hooka can read it (to the second, or to the minute). For a message sent through Hooka (a campaign DM, or a reply sent through the API or the dashboard), the time Hooka recorded it as sent (a campaign DM read before the person answers can show TikTok's time until then). Otherwise an estimate (when Hooka read the thread, or the time of the message next to it) that Hooka corrects if TikTok later shows a time.
    MessageList:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object: { type: string, const: list }
        data: { type: array, items: { $ref: '#/components/schemas/Message' } }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'], description: Id of the last item when `has_more` is true; `null` otherwise. }
    ReplyCreate:
      type: object
      required: [text]
      properties:
        text:
          type: string
          minLength: 1
          maxLength: 2000
          description: The reply, 1–2000 characters, not only whitespace.
    ReplyQueued:
      type: object
      required: [id, object, status]
      properties:
        id:
          type: string
          pattern: '^rpl_[0-9a-f-]{36}$'
          description: Id of the queued reply. It is not a `msg_` id and cannot be looked up.
        object: { type: string, const: message }
        status: { type: string, const: queued }

    # ─────────── Webhook endpoints ───────────
    EventType:
      type: string
      enum:
        - sender.connection.qr_generated
        - sender.connection.verification_required
        - sender.connected
        - sender.connection_failed
        - sender.status_changed
        - campaign.started
        - campaign.completed
        - target.sent
        - target.failed
        - target.skipped
        - target.not_delivered
        - target.replied
        - message.received
        - ping
      description: The 13 subscribable types, plus `ping` (test events only). There is no `campaign.failed`.
    SubscribableEventType:
      type: string
      enum:
        - '*'
        - sender.connection.qr_generated
        - sender.connection.verification_required
        - sender.connected
        - sender.connection_failed
        - sender.status_changed
        - campaign.started
        - campaign.completed
        - target.sent
        - target.failed
        - target.skipped
        - target.not_delivered
        - target.replied
        - message.received
      description: An event type, or `"*"` for every type (including types added later). There are no other wildcards (`target.*` is rejected).
    WebhookEndpoint:
      type: object
      required: [id, object, url, enabled_events, status, created]
      properties:
        id: { type: string, pattern: '^we_[0-9a-f-]{36}$' }
        object: { type: string, const: webhook_endpoint }
        url: { type: string }
        enabled_events:
          type: array
          items: { type: string }
          description: Subscribed event types (deduplicated), or `["*"]`.
        status: { type: string, enum: [enabled, disabled] }
        secret:
          type: string
          pattern: '^whsec_[0-9a-f]{48}$'
          description: Signing secret. Only in the response of `POST /webhook_endpoints`.
        created: { $ref: '#/components/schemas/Timestamp' }
    WebhookEndpointWithSecret:
      allOf:
        - $ref: '#/components/schemas/WebhookEndpoint'
        - type: object
          required: [secret]
    WebhookEndpointList:
      type: object
      required: [object, data]
      description: Not paginated.
      properties:
        object: { type: string, const: list }
        data: { type: array, items: { $ref: '#/components/schemas/WebhookEndpoint' } }
    WebhookEndpointCreate:
      type: object
      required: [url, enabled_events]
      properties:
        url:
          type: string
          minLength: 1
          maxLength: 2048
          description: A public `https://` URL. Stored trimmed.
        enabled_events:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/SubscribableEventType' }
          description: 1–50 event types, or `["*"]`. Duplicates are removed.
    WebhookEndpointUpdate:
      type: object
      minProperties: 1
      anyOf:
        - required: [url]
        - required: [enabled_events]
        - required: [status]
      properties:
        url:
          type: string
          minLength: 1
          maxLength: 2048
        enabled_events:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/SubscribableEventType' }
        status: { type: string, enum: [enabled, disabled] }
    WebhookEndpointDeleted:
      type: object
      required: [id, object, deleted]
      properties:
        id: { type: string, pattern: '^we_[0-9a-f-]{36}$' }
        object: { type: string, const: webhook_endpoint }
        deleted: { type: boolean, const: true }

    # ─────────── Events ───────────
    Event:
      type: object
      required: [id, object, type, api_version, created, workspace_id, data]
      description: The same object is returned by the Events API and POSTed to webhook endpoints.
      properties:
        id:
          type: string
          pattern: '^evt_[0-9a-f-]{36}$'
          description: Stable across delivery retries; equals the `Hooka-Event-Id` header.
        object: { type: string, const: event }
        type: { $ref: '#/components/schemas/EventType' }
        api_version: { type: string, const: '2026-06-01' }
        created:
          $ref: '#/components/schemas/Timestamp'
          description: When the event was created (not when it was delivered).
        workspace_id: { type: string, pattern: '^ws_[0-9a-f-]{36}$' }
        data:
          type: object
          required: [object]
          properties:
            object:
              type: object
              description: The payload for this `type`, as documented under *Webhook events*. Key order is not stable.
    EventList:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object: { type: string, const: list }
        data: { type: array, items: { $ref: '#/components/schemas/Event' } }
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'], description: Id of the last item when `has_more` is true; `null` otherwise. In polling mode, when `has_more` is false keep the last item's id (or your old cursor if `data` is empty). }

    # ─────────── Webhook payloads ───────────
    SenderStatusChangedPayload:
      allOf:
        - $ref: '#/components/schemas/Sender'
        - type: object
          required: [previous_status, reason]
          properties:
            previous_status:
              type: string
              enum: [connected, disconnected, cooldown, banned]
            reason: { $ref: '#/components/schemas/SenderStatusReason' }
    TargetEventPayload:
      allOf:
        - $ref: '#/components/schemas/Target'
        - type: object
          required: [sender_id]
          properties:
            sender_id:
              type: [string, 'null']
              description: The sender involved (`snd_…`). See each event for its meaning and when it can be `null`.
    TargetSkippedPayload:
      allOf:
        - $ref: '#/components/schemas/TargetEventPayload'
        - type: object
          required: [reason]
          properties:
            reason: { $ref: '#/components/schemas/TargetSkipReason' }
    TargetRepliedPayload:
      type: object
      description: The target object with `status` set to `replied`, plus the reply details. The stored target stays `sent`.
      required: [id, object, campaign_id, username, variables, status, step, sent_at, error, error_message, sender_id, replied_at, conversation_id, opt_out]
      properties:
        id: { type: string, pattern: '^tgt_[0-9a-f-]{36}$' }
        object: { type: string, const: target }
        campaign_id: { type: string, pattern: '^cmp_[0-9a-f-]{36}$' }
        username: { type: string }
        variables:
          type: object
          additionalProperties: { type: string }
        status: { type: string, const: replied }
        step: { type: integer, minimum: 0 }
        sent_at: { type: [integer, 'null'], description: When this target was sent. }
        error: { $ref: '#/components/schemas/TargetErrorCode' }
        error_message: { type: [string, 'null'] }
        delivery: { type: 'null', description: Always `null` in this payload. }
        sender_id:
          type: string
          pattern: '^snd_[0-9a-f-]{36}$'
          description: The sender whose conversation received the reply.
        replied_at:
          type: [integer, 'null']
          description: Time of the reply message (equal to its `message.received` `at`). TikTok's time when Hooka can read it, otherwise an estimate.
        conversation_id: { type: string, pattern: '^con_[0-9a-f-]{36}$' }
        opt_out:
          type: boolean
          description: True when this reply contains an opt-out word; the handle is then suppressed.
    MessageReceivedPayload:
      allOf:
        - $ref: '#/components/schemas/Message'
        - type: object
          required: [sender_id, username, opt_out]
          properties:
            sender_id:
              type: string
              pattern: '^snd_[0-9a-f-]{36}$'
              description: The sender whose inbox the conversation belongs to.
            username:
              type: [string, 'null']
              description: The conversation's handle as stored (not normalized), or `null` when unknown.
            opt_out:
              type: boolean
              description: The text contains the whole word `stop`, `unsubscribe`, `baja` or `desuscribir` (any case).
    PingPayload:
      type: object
      required: [message]
      properties:
        message: { type: string, const: This is a Hooka test event. }
