1. Home
  2. API
API · REST v1 · JSON over HTTPS

The TikTok DM outreach API

TikTok has no official API for sending cold DMs — its Business Messaging API only lets you reply to users who message you first. Hooka's REST API is the managed programmatic layer for outbound TikTok DMs: connect your TikTok accounts by QR, create campaigns with message variants and follow-ups, add up to 1,000 targets per request, and get sends and replies back by signed webhook or from a polling events feed.

Last updated: 30 Sep 2026 · By the Hooka team

Is there an official TikTok API for sending DMs?

No. As of June 2026, TikTok offers no official API for initiating direct messages to people who haven't contacted you first. The only official messaging surface — the Business Messaging API — is built for inbound support, not outreach.

Its constraints make that explicit: a user must message your business first, you get a 48-hour reply window, you can send roughly 10 automated messages per window, and broadcasting is disallowed. That's exactly why inbound tools built on it (ManyChat being the best-known) cannot initiate a single cold DM.

So every true cold-DM tool on the market operates outside the official API — including Hooka. We say that plainly because it shapes the whole design: the Hooka API is a managed programmatic layer over real TikTok accounts. You call clean REST endpoints; Hooka runs real browsers with each account's session, connected through a dedicated residential IP in your accounts' country, enforces message spacing and per-account daily caps server-side, and when TikTok shows a "sending too fast" warning it puts the message back in the queue and pauses that account for 5 minutes. The numbers behind those defaults are in our guide to TikTok DM limits in 2026.

300
requests / minute per workspace
1,000
targets per request
12
webhook event types
24h
idempotency key window

What do you need before the first call?

  1. An active (or trialing) subscription. Your plan sets how many TikTok accounts you can connect (seats). Without one, POST /v1/senders returns 403 no_active_subscription and the engine sends nothing, even if a campaign shows as running and the API returns no error.
  2. Each account's country. Every account you connect gets its own dedicated residential IP in its country, so TikTok doesn't see a login from a data centre. Pass country (ISO 3166-1 alpha-2) when you create the sender, or let it use your workspace's default country. It can't be changed later.
  3. An hk_live_ API key. Create it on the Developers page of your dashboard. It is stored only as a sha256 hash and shown exactly once, so copy it when you create it.

One call tells you whether you're ready: seats.used must be below seats.limit, and exit_ip.region is the default country new senders get when you don't pass country.

export HOOKA_API_KEY="hk_live_..."   # your key, from the Developers page

curl https://api.hooka.to/v1/workspace \
  -H "Authorization: Bearer $HOOKA_API_KEY"

# → 200
{
  "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 }
}

"no_region" only means there is no confirmed default country: it doesn't block, pass country when you create the sender. If something is missing, POST /v1/senders answers 400 country_required, 403 no_active_subscription or 403 sender_limit_reached, and 409 exit_ip_not_ready (with retry_after) if there's no IP available right now in that country or its nearest ones.

How does the Hooka API work?

It works the way you'd expect if you've used Stripe: REST over HTTPS, JSON, bearer keys, typed object IDs, cursor pagination and idempotency. The conventions:

  • Base URL: every endpoint lives under https://api.hooka.to/v1. It is meant for server-to-server calls: don't put your key in code that runs in the browser.
  • Keys: send Authorization: Bearer hk_live_.... hk_test_ keys exist (POST /v1/api_keys with {"mode": "test"}) but are read-only: any POST, PATCH or DELETE returns 403 test_key_read_only. There is no sandbox: every write acts on your real TikTok accounts.
  • JSON only: send every body with Content-Type: application/json, or you get 415 unsupported_content_type (watch out: curl -d without that header sends a form). Bodies are limited to 1 MB.
  • Typed IDs: every ID tells you what it points at — snd_ senders (TikTok accounts), cmp_ campaigns, tgt_ targets, con_ conversations, msg_ messages, rpl_ queued replies (returned by POST /v1/conversations/{id}/messages; no endpoint fetches them), sup_ suppressions, we_ webhook endpoints, evt_ events, key_ API keys and ws_ your workspace.
  • Timestamps are integer Unix seconds, UTC (created, sent_at…).
  • Pagination: lists return {"object": "list", "data": [...], "has_more": true, "next_cursor": "cmp_..."}. Pass limit (1–100, default 50) and, while has_more is true, fetch the next page with starting_after=<next_cursor>. Two exceptions: on GET /v1/events?after=... you continue with after=<next_cursor>, never starting_after (next to after it is a 400, and on its own it returns the events before the cursor, newest first); and GET /v1/webhook_endpoints and GET /v1/api_keys are not paginated: they return every item as {"object": "list", "data": [...]}, with no has_more or next_cursor, and ignore limit.
  • Idempotency: every POST except POST /v1/api_keys accepts an Idempotency-Key header (1–255 characters). Keys are kept for 24 hours per workspace. Retrying with the same key and the same request replays the stored response with Idempotent-Replayed: true instead of acting twice. The same key with a different request gives 409 idempotency_key_reuse; while the original is still running you get 409 idempotency_key_in_use — retry shortly with the same key. 5xx, 429 and errors that depend on the state of the moment (such as no_sender_available) are not stored, so a retry with the same key runs again. A replay is a snapshot of the original response, not the current state.
  • Rate limit: 300 requests per minute per workspace, shared by all your keys and OAuth tokens. Responses carry x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset; over the limit you get 429 rate_limited with a Retry-After header and retry_after in the body.
  • Tracing: every response carries a Hooka-Request-Id header, repeated as error.request_id in errors. Quote it in support tickets.

Errors

Every error uses one envelope. code is the stable value to branch on, and param names the offending field when there is one:

{
  "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"
  }
}

type almost always sets the HTTP status: invalid_request_error 400, authentication_error 401, permission_error 403, not_found_error 404, conflict_error 409, rate_limit_error 429 and api_error 500. The two exceptions: invalid_request_error also comes with 413 (body_too_large) and 415 (unsupported_content_type), and api_error can be any 5xx. A resource from another workspace behaves exactly like one that doesn't exist (404).

Most endpoints silently ignore body fields they don't know, so check your spelling: a made-up setting such as spacing_seconds is simply dropped (the spacing between DMs is settings.rate_limit_seconds). The exception is PATCH /v1/senders/{id}, which accepts only label and rejects any other field with a 400 whose param names that field. A PATCH /v1/webhook_endpoints/{id} with none of url, enabled_events or status is also a 400.

Campaigns you create through the API and the ones you create in the dashboard use the same sending engine and show up in both places: the whole feature set, from sender rotation to the unified inbox, works on the same data as your calls.

Quickstart: from API key to a running campaign

This is the entire core flow in four steps: connect a sender, create a campaign, add targets, start it — then listen for events. The examples use the HOOKA_API_KEY variable from the previous section; replace the IDs with the ones your API returns.

1. Connect a TikTok account (sender)

A sender is one of your TikTok accounts. Connection is QR-only: Hooka never asks for your TikTok password. POST /v1/senders creates the sender, with its own dedicated residential IP in the account's country, and opens a connection attempt, but the QR is not in the response: a browser produces it once the IP is ready, usually within seconds (exit_ip.status is provisioning meanwhile, a few minutes at most when a new IP has to be bought).

curl -X POST 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" }'

# → 201
{
  "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
}

Poll the sender every 3–5 seconds and act on connection.status. The sender.connection.qr_generated webhook tells you when the first QR is ready (and carries it), but it is a signal to start polling, not a replacement: the QR can also change without any event, so keep polling while you show it.

export SENDER_ID="snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10"

curl https://api.hooka.to/v1/senders/$SENDER_ID \
  -H "Authorization: Bearer $HOOKA_API_KEY"
  • pending — the account's IP is being prepared, the browser is starting, or TikTok is loading a fresh QR. Wait, and don't scan an old QR.
  • requires_scan — connection.qr_code holds a PNG as a data URI (data:image/png;base64,...). Show it and scan it from the TikTok app, logged into the account you want to connect. Always show the latest QR: if nobody scans it for about 2 minutes, it is replaced.
  • scanned — TikTok detected the scan; the user confirms the login on the phone.
  • verification_required — TikTok asked "Verify it's you". connection.verification.step says what it wants: choose_method (pick an email or phone from verification.options) or enter_code (send the code the user received) — answer both with …/connection/verify, below. Two more steps take no action (the endpoint rejects them): finishing — TikTok is completing the login, usually because the code was accepted; keep polling — and unsupported — TikTok asked for something Hooka can't handle, such as a captcha or a password. Unless TikTok moves on to a supported step, that attempt ends with verification_unsupported; you can start a new one right away with …/connection/refresh.
  • Done when the sender's status is "connected": username is filled in, connection becomes null and the sender.connected webhook fires. If connection.status turns "expired" or "failed", or status turns "disconnected", the attempt is over.
curl -X POST https://api.hooka.to/v1/senders/$SENDER_ID/connection/verify \
  -H "Authorization: Bearer $HOOKA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: verify-sales-es-1-code-1" \
  -d '{ "action": "submit_code", "code": "123456" }'

# Other actions:
#   { "action": "choose_method", "option_id": 0 }   (an id from verification.options)
#   { "action": "resend_code" }                      (when verification.resend_available is true)

The verify call answers 202 as soon as the action is queued, before TikTok applies it, so keep polling for the result. Use a new Idempotency-Key for each code. A wrong code keeps the step at enter_code with TikTok's error in verification.messages. When an attempt fails, the reason is in connection.error while the sender is still awaiting_scan (status_reason is null), and in status_reason once it is disconnected. POST /v1/senders/{id}/connection/refresh opens a new attempt.

Connect accounts one at a time: every POST /v1/senders or …/connection/refresh cancels any other attempt in progress in the workspace, which ends with status_reason: "superseded". An attempt lasts about 5 minutes from the first QR, longer if TikTok asks for verification.

2. Create a campaign

A new campaign is always draft: nothing is sent until you start it. The main fields:

  • message: a string, or {"variants": [...]} with up to 20 variants of 1 to 2,000 characters; one is picked at random for each recipient and step.
  • follow_ups: up to 10 steps of {"wait_days": 1–60, "variants": [...]}. wait_days counts from the previous step's send. A follow-up only goes out if the person hasn't replied, and always from the account that sent the previous step.
  • Variables: {{first_name}} or any key you put in the targets' variables; {{username}} (alias {{handle}}) is the recipient and {{my_handle}} the sending account. With on_missing_variable: "skip", a target missing a variable is skipped; with "blank" (the default), it is sent with the gap empty.
  • settings.sender_ids (1–10 snd_ IDs): the accounts to send from. Leave it out and the campaign takes up to 10 of your oldest connected accounts at that moment. The API can't change the set afterwards (the dashboard can).
  • settings.rate_limit_seconds (30–3,600, default 60): the minimum gap between two DMs from the same account.
  • settings.daily_limit (1–500, or null): a daily cap for the campaign, all its accounts combined. Each account's own cap still applies.
  • settings.dedupe_across_campaigns (default true): skips anyone another of your campaigns has already sent a DM to.
curl -X POST https://api.hooka.to/v1/campaigns \
  -H "Authorization: Bearer $HOOKA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ugc-october-create" \
  -d '{
    "name": "UGC outreach - October",
    "client_reference": "crm-4412",
    "message": {
      "variants": [
        "Hey {{first_name}}, loved your last video. Open to a paid UGC collab?",
        "Hi {{first_name}}, we pay creators like you for UGC videos. Interested?"
      ]
    },
    "follow_ups": [
      { "wait_days": 3, "variants": ["{{first_name}}, just bumping this in case it got buried."] }
    ],
    "settings": {
      "sender_ids": ["snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10"],
      "rate_limit_seconds": 90,
      "daily_limit": 40,
      "on_missing_variable": "skip"
    }
  }'

# → 201 (the campaign, plus the result for any targets sent inline — none here)
{
  "id": "cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41",
  "object": "campaign",
  "platform": "tiktok",
  "name": "UGC outreach - October",
  "client_reference": "crm-4412",
  "status": "draft",
  "message": {
    "variants": [
      "Hey {{first_name}}, loved your last video. Open to a paid UGC collab?",
      "Hi {{first_name}}, we pay creators like you for UGC videos. Interested?"
    ]
  },
  "follow_ups": [
    { "wait_days": 3, "variants": ["{{first_name}}, just bumping this in case it got buried."] }
  ],
  "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": 0,
  "target_ids": [],
  "targets_skipped": []
}

daily_limit_per_sender is a deprecated alias of daily_limit: despite the name, it is the campaign's cap, not each account's. Use daily_limit. Save the ID:

export CAMPAIGN_ID="cmp_4f0c2d9e-1b7a-4c1e-9a53-2e8d7c6b5a41"

3. Add targets

Up to 1,000 per request (you can also pass the same list as targets when creating the campaign). Each item is a username string or {"username": ..., "variables": {...}}; variable values can be strings, numbers or booleans and are stored as strings.

curl -X POST https://api.hooka.to/v1/campaigns/$CAMPAIGN_ID/targets \
  -H "Authorization: Bearer $HOOKA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ugc-october-batch-1" \
  -d '{
    "targets": [
      { "username": "creator.one", "variables": { "first_name": "Mia" } },
      { "username": "creator_two", "variables": { "first_name": "Leo", "followers": 12000 } },
      { "username": "@Creator.One", "variables": { "first_name": "Mia" } },
      { "username": "bad handle!", "variables": { "first_name": "Ana" } }
    ]
  }'

# → 202
{
  "accepted": 2,
  "target_ids": [
    "tgt_9a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
    "tgt_2e3f4a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b"
  ],
  "skipped": [
    { "username": "@Creator.One", "reason": "duplicate_in_campaign" },
    { "username": "bad handle!", "reason": "invalid" }
  ],
  "campaign_status": "draft",
  "resumed": false
}

Usernames are normalized (trimmed, leading @ removed, lowercased), which is why @Creator.One counts as a duplicate of creator.one. A valid username has 1 to 40 characters: ASCII letters, digits, _, . and -. Skipped items never become targets and fire no event. The reasons: invalid (not a valid TikTok username), duplicate_in_campaign (already in this campaign, in any status), suppressed (on your Do-Not-Contact list) and duplicate_across_campaigns (another of your campaigns already sent this person a DM, when the campaign deduplicates).

What happens next depends on the campaign's status. In a running campaign, new targets can go out within seconds: they are real DMs. In draft or paused they wait, because adding targets never starts or resumes a campaign. Adding targets to a completed campaign reopens it when one of its senders is connected ("resumed": true, with a new campaign.started). If none is connected, the campaign stays completed with the new targets pending, and the response adds "resume_blocked_reason": "no_connected_sender" (a field that only appears in this case): reconnect a sender, then call POST …/start or POST …/resume.

4. Start it

curl -X POST https://api.hooka.to/v1/campaigns/$CAMPAIGN_ID/start \
  -H "Authorization: Bearer $HOOKA_API_KEY" \
  -H "Idempotency-Key: ugc-october-start"

# → 200: the full campaign, now running, with live stats (excerpt)
"status": "running",
"stats": { "total": 2, "queued": 2, "pending": 2, "processing": 0, "sent": 0, "replied": 0, "failed": 0, "skipped": 0, "reply_rate": 0 }

No body is needed. start needs at least one of the campaign's senders connected (otherwise 400 no_sender_available) and works from draft, paused, or completed with pending targets (otherwise 409 invalid_campaign_state). POST …/pause (from running) and POST …/resume (from paused, or from completed with pending targets) are called the same way. These are real DMs. From here the engine takes over:

  • Order: targets go out in the order you added them, with due follow-ups first. Each connected sender of the campaign takes one target at a time, so sends spread across up to 10 senders.
  • Spacing: at least rate_limit_seconds between two DMs from the same sender, never less than 30 seconds.
  • Daily caps: each sender has its own cap across all its campaigns, and daily_limit caps the campaign on top (see limits and sending pace).
  • Follow-ups are sent by the same account that sent the previous step, and only if the person hasn't replied. If that account is disconnected, the follow-up waits for it.
  • Lifecycle: if every sender of a running campaign disconnects, the campaign is paused automatically — with no webhook — and stays paused until you call resume (or start). completed means nothing is pending right now; the campaign reopens, with a new campaign.started, when follow-ups come due, when you add targets while one of its senders is connected, or when you call start or resume while it still has pending targets.
  • Progress: GET /v1/campaigns/{id} returns live stats. sent counts target rows, follow-up steps included; replied counts people, and reply_rate is replied ÷ people contacted. GET /v1/campaigns/{id}/targets?status=failed lists the failures.

What does "sent" mean, and how do sends fail?

Each target moves from pending to processing and ends as sent, failed or skipped. Every follow-up step is its own target, with its own tgt_ ID and a step number. Watch them with the target.sent, target.failed and target.skipped webhooks, or list them with GET /v1/campaigns/{id}/targets?status=failed. A reply doesn't change a target's status — it stays sent — and is reported by the target.replied event.

For each target, the engine opens the recipient's profile, clicks Message, pastes the text and sends it. sent means TikTok accepted the message: the chat box emptied after sending, and no "not delivered" notice appeared within about 8 seconds. It is not a delivery or read receipt, and a first contact can land in the recipient's message requests rather than their main inbox. sent_at is when Hooka recorded the send.

TikTok's content block (blocked_by_platform) is detected in any language. "Not delivered" notices, including the privacy one (recipient_privacy), are recognised by their wording in the account's interface language, and Spanish, English, Portuguese, French, Italian and German are covered. Such a notice in another language, or any notice that appears later, goes undetected and the target stays sent.

Failures carry a stable error code, with details in error_message (TikTok's literal notice when there is one):

CodeWhat happenedRetried?
blocked_by_platformTikTok took the text but didn't deliver it, and marked it in the conversation (a possible community-guidelines flag).No: the same text would be blocked again.
recipient_privacyTikTok said the recipient's privacy settings don't allow your message.No.
recipient_unreachableNo chat could be opened: the profile has no Message button (private account, doesn't accept messages from strangers, or gone).No.
send_unconfirmedHooka couldn't confirm the send, or it hung for more than 15 minutes. The DM may or may not have gone out; a later target.sent can correct it.No, to avoid a duplicate DM.
sender_session_expiredThe account's session was no longer valid.No: reconnect the sender.
send_failedAnything else, including a TikTok "not delivered" notice with no stated reason.No.
rate_limitedTikTok throttled the account.Yes, automatically: the target goes back to pending and fires no target.failed.

A target can also end as skipped, without anything being sent. The reason is one of suppressed (on your Do-Not-Contact list), duplicate_in_campaign, duplicate_across_campaigns, recipient_replied (a follow-up cancelled because the person replied), sender_unavailable (the account that had to send the follow-up was removed from the campaign or deleted with DELETE /v1/senders/{id}), missing_variable or empty_message.

Limits and sending pace

ControlScopeValueWhere to change it
Account daily capOne account, across all its campaigns30 by default, up to 100Dashboard (not the API)
Warm-up (optional)One accountFrom 10 a day up to the cap over 14 daysDashboard
settings.daily_limitOne campaign, all its accounts combined1 to 500, or null (no campaign cap)When creating the campaign, or in the dashboard
settings.rate_limit_secondsEach account, between two DMs30 to 3,600 s; 60 by default. Ignored if you switch the campaign to time-window pacing in the dashboardWhen creating the campaign, or in the dashboard
Pause after a TikTok warningOne account5 minutesAutomatic
API requestsThe whole workspace300 per minute—

Daily caps count attempts, blocked and unconfirmed ones included, except when nothing was typed. A sender's sent_today counts only successful sends, so an account can stop for the day before sent_today reaches its daily_limit. The day is counted in UTC. With an active subscription there is no monthly DM cap.

How do webhooks work?

Register an HTTPS endpoint on the public internet and pick the events you want (or ["*"] for all of them, including types added later):

curl -X POST 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.skipped", "target.replied", "campaign.completed", "message.received"]
  }'

# → 201
{
  "id": "we_5c0d8f4e-1b2a-4c3d-8e9f-0a1b2c3d4e5f",
  "object": "webhook_endpoint",
  "url": "https://example.com/hooka/webhooks",
  "enabled_events": ["target.sent", "target.failed", "target.skipped", "target.replied", "campaign.completed", "message.received"],
  "status": "enabled",
  "secret": "whsec_2b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da5",
  "created": 1790781000
}

The signing secret is returned only in this response — neither GET nor PATCH returns it again, so store it. There are 12 event types:

EventFires when
sender.connection.qr_generatedThe first QR of a connection attempt is ready, for a sender you created or refreshed through the API; it fires again whenever Hooka reloads the QR page or the login falls back to the QR screen. Other QR changes update connection.qr_code without an event, so poll GET /v1/senders/{id} for the latest QR while it is shown. Carries the sender with connection.qr_code.
sender.connection.verification_requiredTikTok asks "Verify it's you" after the scan, or the verification moves to another step (choose_method, enter_code or unsupported; not finishing). Answer the first two with …/connection/verify.
sender.connectedA login completed and the sender is connected.
sender.connection_failedAn API-started connection attempt ended without success; connection.error says why (for example connection_expired, verification_timeout or superseded). Cancelling the attempt yourself with DELETE /v1/senders/{id} emits no event.
sender.status_changedA connected sender became disconnected — session expired, or disconnected from the dashboard or the API. Adds previous_status and reason.
campaign.startedA campaign became running from draft or completed: start from draft; start or resume of a completed campaign that still has pending targets; targets added to a completed campaign; follow-ups coming due. Not sent when a campaign is paused, or resumed (or started) from paused.
campaign.completedA running or paused campaign has nothing pending or in progress. It can fire again after the campaign reopens.
target.sentA DM or follow-up step was sent (see what "sent" means).
target.failedA send failed, or hung for more than 15 minutes (send_unconfirmed). error has the code.
target.skippedA target was resolved without sending; reason has the code.
target.repliedThe recipient replied after your DM. At most once per target; adds replied_at, conversation_id and opt_out.
message.receivedAny new inbound DM in any synced conversation, not only campaign replies. Adds sender_id, username and opt_out.

Every target.* event carries the target object plus sender_id, the account that handled it (null when no account was involved, such as a skip caused by your Do-Not-Contact list).

No event fires when a campaign is paused (by you or automatically) or resumed from paused, when targets are added, or when a reply you send through the API goes out or fails — poll GET /v1/campaigns/{id} to spot a pause. Reopening a completed campaign is different: starting or resuming it while it still has pending targets, or adding targets to it while one of its senders is connected, does emit campaign.started.

An inbound message containing the whole word "stop", "unsubscribe", "baja" or "desuscribir" (anywhere in the text, so "don't stop" counts too) arrives with opt_out: true, and once the conversation's username is known Hooka adds that person to your Do-Not-Contact list and skips their pending targets.

What a delivery looks like

Each delivery is a POST whose body is the event object — the same object GET /v1/events returns. This is how a reply to your DM arrives:

POST /hooka/webhooks HTTP/1.1
Content-Type: application/json
Hooka-Signature: t=1790781503,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Hooka-Event-Id: evt_6f1c2a4e-8a55-4c47-9a0e-3b1f0d2c9e11
Hooka-Event-Type: target.replied
User-Agent: Hooka-Webhooks/1.0

{
  "id": "evt_6f1c2a4e-8a55-4c47-9a0e-3b1f0d2c9e11",
  "object": "event",
  "type": "target.replied",
  "api_version": "2026-06-01",
  "created": 1790781500,
  "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": "Mia" },
      "status": "replied",
      "step": 0,
      "sent_at": 1790765000,
      "error": null,
      "error_message": null,
      "sender_id": "snd_5f0c2a8e-7c1b-4b8e-9d3a-2f6a1c9e4b10",
      "replied_at": 1790781500,
      "conversation_id": "con_7d8e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a",
      "opt_out": false
    }
  }
}

"status": "replied" exists only in this event: the target itself stays sent in GET …/targets, which can't filter by replied. The key order inside data can vary, so don't rely on it.

Verifying the signature

Every delivery is HMAC-SHA256 signed: v1 is the hex HMAC of t + "." + raw body, keyed with the full secret string, including the whsec_ prefix, and t is the time of that delivery attempt (it is recomputed on every retry). Verify over the raw bytes — re-serialized JSON won't match — and reject signatures whose t is more than 5 minutes away from your clock. In Node.js with Express:

import crypto from 'node:crypto';
import express from 'express';

function verifyHookaSignature(rawBody, header, secret, toleranceSec = 300) {
  if (!header) return false;
  const parts = {};
  for (const kv of header.split(',')) {
    const i = kv.indexOf('=');
    if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  const got = Buffer.from(parts.v1, 'hex');
  return got.length === expected.length && crypto.timingSafeEqual(got, expected);
}

const app = express();

// express.raw keeps the exact bytes Hooka signed.
app.post('/hooka/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  // HOOKA_WEBHOOK_SECRET is the full "whsec_..." string.
  if (!verifyHookaSignature(req.body, req.get('Hooka-Signature'), process.env.HOOKA_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  res.sendStatus(200); // answer within 10 s, then do the work
  const event = JSON.parse(req.body.toString('utf8'));
  // Delivery is at-least-once: skip event.id values you have already processed.
});

app.listen(3000);

Delivery rules

  • Only a 2xx answered within 10 seconds counts as delivered. Redirects are not followed, and any other status — 4xx included — is retried.
  • Retries come 1 min, 5 min, 30 min, 2 h, 6 h, 24 h and 24 h after each failure: at most 8 attempts, over about 2.4 days, after which the delivery is marked failed. There is no replay or manual redelivery, the API doesn't expose delivery logs (the Developers page in your dashboard shows each endpoint's last 20 deliveries), and an endpoint is never disabled automatically.
  • Delivery is at-least-once and unordered: deduplicate on the event id (also sent as Hooka-Event-Id) and don't rely on arrival order.
  • Each event is a snapshot of the object at the moment it happened, not its current state.
  • The URL must use HTTPS, point to a public domain (no private IPs or internal names) and carry no username or password. Hooka publishes no IP allowlist: the signature is the guarantee.
  • Test your receiver with POST /v1/webhook_endpoints/{id}/test, which sends a signed ping event to that endpoint only.

Polling instead: GET /v1/events

Can't host an endpoint, or want a safety net? Poll GET /v1/events. It holds the same 12 event types (plus test pings) for 30 days, filterable with type=. Take the newest event as your starting cursor, then read forward, oldest first:

# Starting cursor: the newest event's id
curl "https://api.hooka.to/v1/events?limit=1" \
  -H "Authorization: Bearer $HOOKA_API_KEY"

# Then loop: events after the cursor, oldest first
export LAST_EVENT_ID="evt_6f1c2a4e-8a55-4c47-9a0e-3b1f0d2c9e11"   # data[0].id from above
curl "https://api.hooka.to/v1/events?after=$LAST_EVENT_ID&limit=100" \
  -H "Authorization: Bearer $HOOKA_API_KEY"

Process data in order and move your cursor to the last ID you received. While has_more is true, fetch again straight away with after=<next_cursor> (never starting_after); otherwise wait a few seconds. Forward polling only returns events more than 2 seconds old, so very fresh events show up on the next poll. Without after, the list runs newest first and starting_after pages backwards.

A new workspace has no events yet, so the first call returns "data": [] and there is no cursor to start from (after= needs an existing event ID, or you get 404 not_found). In that case, poll GET /v1/events without after every few seconds until the first event appears, then switch to after=.

What resources does the API expose?

The API covers the full outreach loop: connecting accounts, running campaigns, suppressing contacts, reading and answering conversations, and receiving events.

ResourceEndpointsWhat it does
WorkspaceGET /v1/workspaceWho you are, your free seats (seats) and the default country for new accounts (exit_ip.region).
SendersPOST · GET /v1/senders
GET · PATCH · DELETE /v1/senders/{id}
POST /v1/senders/{id}/connection/refresh
POST /v1/senders/{id}/connection/verify
Connect accounts by QR, complete "Verify it's you", read their status and status_reason, rename them (label) and disconnect them. DELETE disconnects the account (it stays in the list and can be reconnected) and cancels the pending follow-ups that depended on it. Statuses: awaiting_scan, connected, disconnected (cooldown and banned are reserved).
CampaignsPOST · GET /v1/campaigns
GET /v1/campaigns/{id}
POST /v1/campaigns/{id}/start · /pause · /resume
Create (always as draft), read with live stats, start, pause and resume. Up to 20 variants per step and 10 follow-up steps of 1–60 days.
TargetsPOST · GET /v1/campaigns/{id}/targetsAdd up to 1,000 per request, with per-target variables and skip reasons, and list them with a status filter. Each follow-up step is a target with its own ID.
SuppressionsPOST · GET /v1/suppressions
DELETE /v1/suppressions/{username}
Your Do-Not-Contact list, shared with the dashboard. Add up to 1,000 usernames per request; their pending targets are skipped at once.
ConversationsGET /v1/conversations
GET /v1/conversations/{id}
GET · POST /v1/conversations/{id}/messages
The synced inbox of all your accounts, and replies from code: POST …/messages queues a real DM from that conversation's account (202).
Webhook endpointsPOST · GET /v1/webhook_endpoints
PATCH · DELETE /v1/webhook_endpoints/{id}
POST /v1/webhook_endpoints/{id}/test
HMAC-SHA256-signed endpoints, plus a test ping.
EventsGET /v1/events
GET /v1/events/{id}
The same events webhooks push, for polling and reconciliation, kept for 30 days.
API keysPOST · GET /v1/api_keys
DELETE /v1/api_keys/{id}
Create keys (live or test), list them and revoke them.

The full reference — every field, status and error code — is in the in-app docs at app.hooka.to/docs.html.

The API has no separate price: Hooka is priced per connected TikTok account, with the API included (see pricing). With an active subscription there is no monthly DM cap, only the per-account daily caps. Without one you can still create keys and read, but you can't connect senders, and campaigns send nothing.

What the API doesn't do

Better to know before you integrate:

  • There is no sandbox or test mode. hk_test_ keys only read; every write acts on real accounts.
  • Campaigns can't be edited or deleted through the API (there is no PATCH or DELETE /v1/campaigns/{id}), and their senders can't be changed after creation. That's done in the dashboard. The texts, senders, spacing and daily cap you change there show up in the API, but some dashboard-only options apply even though settings doesn't show them: time-window pacing (it replaces rate_limit_seconds and only sends inside the window), follow before DM (it raises the minimum spacing to 60 s and, if the account hits its following cap, it can pause the campaign) and per-account deduplication (dedupe_across_campaigns still reads true).
  • An account's country can't be changed after you connect it (Hooka support can move it), and the per-account daily cap and warm-up are set only in the dashboard.
  • No event when a campaign is paused, or resumed from paused. If every sender of a running campaign disconnects, the campaign pauses itself, with no webhook, and doesn't resume on its own when you reconnect: call resume.
  • completed isn't final: the campaign goes back to running (with another campaign.started) when the next follow-up comes due, when you add targets while one of its senders is connected, or when you call start or resume while it still has pending targets.
  • Replies you send through the API fire no events. POST /v1/conversations/{id}/messages returns 202 and the reply is queued, with no sent or failed event. A conversation's status (interested, archived…) is read-only.
  • Someone who failed or was skipped can't be added again to the same campaign, and removing them from the Do-Not-Contact list doesn't revive their targets.
  • One account connection at a time per workspace, as explained in the quickstart.

Building with AI agents? Use the MCP server

Hooka's remote MCP server, at https://mcp.hooka.to/mcp, exposes this same API as tools for MCP clients. Clients that connect with OAuth 2.1 (for example, Claude or ChatGPT custom connectors) sign in with your Hooka account; clients that allow headers, such as Claude Code or Cursor, can send Authorization: Bearer hk_live_.... The rules are the same: hk_test_ keys only read, and MCP requests count toward the same 300-per-minute workspace limit. An OAuth token has the same access as an hk_live_ key, except for managing API keys. More on the Hooka MCP server page.

Honest note: no API parameter removes ban risk. TikTok DM automation operates outside the official API and carries real risk. Hooka reduces it — a daily cap per account (30 by default, 100 at most), at least 30 seconds between two DMs from the same account, optional warm-up, sends spread across up to 10 senders per campaign, automatic pauses when TikTok says you're going too fast and a dedicated residential IP in your accounts' country — but we never promise zero bans. Start low on new accounts, ramp up, and use dedicated outreach accounts.

Frequently asked questions

Is there an official TikTok API for sending cold DMs?

No. As of June 2026, TikTok's only messaging API — the Business Messaging API — is inbound-only: a user has to message your business first, you get a 48-hour reply window with roughly 10 automated messages per window, and broadcasting is disallowed. Every true cold-DM tool, Hooka included, operates outside the official API. Hooka's API adds a managed layer on top — per-account daily caps, spacing between messages and automatic pauses — that reduces the risk but never eliminates it.

How do I get a Hooka API key?

Self-serve, from the Developers page of your Hooka dashboard. Live keys start with hk_live_, are stored only as a sha256 hash and are shown once, when you create them, so store yours somewhere safe. Creating a key needs no subscription, but connecting TikTok accounts and sending DMs does: pricing is per connected TikTok account, with the API included. Test keys (hk_test_) exist but are read-only, because Hooka has no sandbox.

Is there a sandbox or test mode?

No. Nothing in the Hooka API is simulated. hk_test_ keys can only read: any POST, PATCH or DELETE returns 403 test_key_read_only. Every write made with an hk_live_ key acts on your real TikTok accounts, and starting a campaign sends real DMs. To test your webhook receiver, send a signed ping event with POST /v1/webhook_endpoints/{id}/test. To test a campaign, use a dedicated outreach account and a handful of recipients.

Does the API send DMs from my own TikTok accounts?

Yes. You connect each account by scanning a QR code with the TikTok app: POST /v1/senders starts the attempt and GET /v1/senders/{id} gives you the QR once connection.status is requires_scan. Hooka never asks for your TikTok password. If TikTok asks you to verify it's you, answer with the email or SMS code through POST /v1/senders/{id}/connection/verify. Sends then run in browsers Hooka manages with your account's session, through that account's own dedicated residential IP, in its country.

How many DMs a day can each account send?

Each account has its own daily cap: 30 by default, adjustable up to 100 in the dashboard (not through the API), with an optional warm-up that starts at 10 a day and reaches your target in 14 days. On top of that, settings.daily_limit caps each campaign across all its accounts, and settings.rate_limit_seconds (30 to 3600, 60 by default) sets the minimum seconds between two DMs from the same account. Caps count attempts, not only confirmed sends, and the day is counted in UTC. Start low on new accounts and ramp up.

What does “sent” mean in the Hooka API?

That TikTok accepted the message: the chat box emptied after sending and no “not delivered” notice appeared within about 8 seconds. It is not a delivery or read receipt, and a first contact can land in the recipient's message requests rather than their main inbox. When TikTok blocks the message or the profile doesn't accept it, the target is failed with a stable code (blocked_by_platform, recipient_privacy, recipient_unreachable…). A send Hooka cannot confirm fails with send_unconfirmed and is never retried, to avoid a duplicate DM.

Should I use webhooks or poll /v1/events?

Use webhooks in production and keep polling as a safety net. Deliveries are HMAC-SHA256 signed, count as delivered only on a 2xx answered within 10 seconds, and are retried 1 min, 5 min, 30 min, 2 h, 6 h, 24 h and 24 h after each failure: at most 8 attempts, over about 2.4 days. Delivery is at-least-once and unordered, so deduplicate on the event id. GET /v1/events with after= returns the same events, oldest first, and keeps them for 30 days: poll it during local development or to reconcile anything a webhook missed.

Keep reading

Send your first TikTok DM from code

Cancel anytime. We never ask for your TikTok password. Secure payment with Stripe.