- Home
- API
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.
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.
What do you need before the first call?
- An active (or trialing) subscription. Your plan sets how many TikTok accounts you can connect (
seats). Without one,POST /v1/sendersreturns403 no_active_subscriptionand the engine sends nothing, even if a campaign shows asrunningand the API returns no error. - 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. - 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_keyswith{"mode": "test"}) but are read-only: any POST, PATCH or DELETE returns403 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 get415 unsupported_content_type(watch out:curl -dwithout 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 byPOST /v1/conversations/{id}/messages; no endpoint fetches them),sup_suppressions,we_webhook endpoints,evt_events,key_API keys andws_your workspace. - Timestamps are integer Unix seconds, UTC (
created,sent_at…). - Pagination: lists return
{"object": "list", "data": [...], "has_more": true, "next_cursor": "cmp_..."}. Passlimit(1–100, default 50) and, whilehas_moreis true, fetch the next page withstarting_after=<next_cursor>. Two exceptions: onGET /v1/events?after=...you continue withafter=<next_cursor>, neverstarting_after(next toafterit is a 400, and on its own it returns the events before the cursor, newest first); andGET /v1/webhook_endpointsandGET /v1/api_keysare not paginated: they return every item as{"object": "list", "data": [...]}, with nohas_moreornext_cursor, and ignorelimit. - Idempotency: every POST except
POST /v1/api_keysaccepts anIdempotency-Keyheader (1–255 characters). Keys are kept for 24 hours per workspace. Retrying with the same key and the same request replays the stored response withIdempotent-Replayed: trueinstead of acting twice. The same key with a different request gives409 idempotency_key_reuse; while the original is still running you get409 idempotency_key_in_use— retry shortly with the same key. 5xx, 429 and errors that depend on the state of the moment (such asno_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-remainingandx-ratelimit-reset; over the limit you get429 rate_limitedwith aRetry-Afterheader andretry_afterin the body. - Tracing: every response carries a
Hooka-Request-Idheader, repeated aserror.request_idin 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_codeholds 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.stepsays what it wants:choose_method(pick an email or phone fromverification.options) orenter_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 — andunsupported— 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 withverification_unsupported; you can start a new one right away with…/connection/refresh.- Done when the sender's
statusis"connected":usernameis filled in,connectionbecomesnulland thesender.connectedwebhook fires. Ifconnection.statusturns"expired"or"failed", orstatusturns"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_dayscounts 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. Withon_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–10snd_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, ornull): a daily cap for the campaign, all its accounts combined. Each account's own cap still applies.settings.dedupe_across_campaigns(defaulttrue): 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_secondsbetween 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_limitcaps 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(orstart).completedmeans nothing is pending right now; the campaign reopens, with a newcampaign.started, when follow-ups come due, when you add targets while one of its senders is connected, or when you callstartorresumewhile it still has pending targets. - Progress:
GET /v1/campaigns/{id}returns livestats.sentcounts target rows, follow-up steps included;repliedcounts people, andreply_rateis replied ÷ people contacted.GET /v1/campaigns/{id}/targets?status=failedlists 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):
| Code | What happened | Retried? |
|---|---|---|
blocked_by_platform | TikTok 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_privacy | TikTok said the recipient's privacy settings don't allow your message. | No. |
recipient_unreachable | No chat could be opened: the profile has no Message button (private account, doesn't accept messages from strangers, or gone). | No. |
send_unconfirmed | Hooka 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_expired | The account's session was no longer valid. | No: reconnect the sender. |
send_failed | Anything else, including a TikTok "not delivered" notice with no stated reason. | No. |
rate_limited | TikTok 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
| Control | Scope | Value | Where to change it |
|---|---|---|---|
| Account daily cap | One account, across all its campaigns | 30 by default, up to 100 | Dashboard (not the API) |
| Warm-up (optional) | One account | From 10 a day up to the cap over 14 days | Dashboard |
settings.daily_limit | One campaign, all its accounts combined | 1 to 500, or null (no campaign cap) | When creating the campaign, or in the dashboard |
settings.rate_limit_seconds | Each account, between two DMs | 30 to 3,600 s; 60 by default. Ignored if you switch the campaign to time-window pacing in the dashboard | When creating the campaign, or in the dashboard |
| Pause after a TikTok warning | One account | 5 minutes | Automatic |
| API requests | The whole workspace | 300 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:
| Event | Fires when |
|---|---|
| sender.connection.qr_generated | The 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_required | TikTok 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.connected | A login completed and the sender is connected. |
| sender.connection_failed | An 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_changed | A connected sender became disconnected — session expired, or disconnected from the dashboard or the API. Adds previous_status and reason. |
| campaign.started | A 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.completed | A running or paused campaign has nothing pending or in progress. It can fire again after the campaign reopens. |
| target.sent | A DM or follow-up step was sent (see what "sent" means). |
| target.failed | A send failed, or hung for more than 15 minutes (send_unconfirmed). error has the code. |
| target.skipped | A target was resolved without sending; reason has the code. |
| target.replied | The recipient replied after your DM. At most once per target; adds replied_at, conversation_id and opt_out. |
| message.received | Any 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 asHooka-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 signedpingevent 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.
| Resource | Endpoints | What it does |
|---|---|---|
| Workspace | GET /v1/workspace | Who you are, your free seats (seats) and the default country for new accounts (exit_ip.region). |
| Senders | POST · GET /v1/sendersGET · PATCH · DELETE /v1/senders/{id}POST /v1/senders/{id}/connection/refreshPOST /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). |
| Campaigns | POST · GET /v1/campaignsGET /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. |
| Targets | POST · GET /v1/campaigns/{id}/targets | Add 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. |
| Suppressions | POST · GET /v1/suppressionsDELETE /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. |
| Conversations | GET /v1/conversationsGET /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 endpoints | POST · GET /v1/webhook_endpointsPATCH · DELETE /v1/webhook_endpoints/{id}POST /v1/webhook_endpoints/{id}/test | HMAC-SHA256-signed endpoints, plus a test ping. |
| Events | GET /v1/eventsGET /v1/events/{id} | The same events webhooks push, for polling and reconciliation, kept for 30 days. |
| API keys | POST · GET /v1/api_keysDELETE /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
PATCHorDELETE /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 thoughsettingsdoesn't show them: time-window pacing (it replacesrate_limit_secondsand 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_campaignsstill readstrue). - 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: callresume. completedisn't final: the campaign goes back torunning(with anothercampaign.started) when the next follow-up comes due, when you add targets while one of its senders is connected, or when you callstartorresumewhile it still has pending targets.- Replies you send through the API fire no events.
POST /v1/conversations/{id}/messagesreturns202and 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
Sender rotation, unified inbox, A/B testing — the engine your API calls drive.
mcpHooka MCP serverRun senders, campaigns and the inbox from Claude, ChatGPT or Cursor over the same API.
pricingHooka pricingPriced per connected TikTok account, with the API included.
guideTikTok DM limits in 2026Community-estimated daily caps and how Hooka's per-account caps map to them.
Send your first TikTok DM from code
Cancel anytime. We never ask for your TikTok password. Secure payment with Stripe.