For developers
Two calls between you and a verified user.
Send the code with verify/start and check it with verify/check.
Tawked Verify is an OTP verification API for Saudi numbers.
The full developer portalGuides, an interactive reference you can call from the page, every page as Markdown, and an MCP server for your coding agent.Open docs.tawked.comQuickstart
- 01
Create an API key
Create a tk_test_ key in your application's Keys tab. It works before application approval.
- 02
Send the code
POST to /v1/verify/start with a destination, and you get back an id and an expiry.
- 03
Check the code
POST to /v1/verify/check with the same id and the code the user typed.
Channels
Verification codes over WhatsApp are coming soon; SMS works today. WhatsApp template messages, such as order updates and reminders, have their own endpoint below, from your own connected number.
| Channel | channel value | Accepted destination formats | Notes | Price |
|---|---|---|---|---|
| SMS | sms | +966512345678, 966512345678, 00966512345678, 0512345678, 512345678 | Any local format is normalized to +9665XXXXXXXX automatically before sending. | 0.09 SAR per code sent |
Authentication
Pass your key in the Authorization header as a Bearer token, or in the x-api-key header if that is not possible. When both are present, Authorization wins.
Live key
Authorization: Bearer tk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6Test key
Authorization: Bearer tk_test_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6Partner key
Authorization: Bearer tk_partner_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6A partner key names the application in the application field of the request body, or in service, its legacy name, since one partner key covers every one of its stores.
Key scopes and hardening
Set when you create the key in the console. All optional.
- `expires_at` (optional): once it passes, every call with that key answers `401 key_expired`, checked right after the key is recognised, before anything else.
- `ip_allowlist` (optional): exact IPs and/or CIDR ranges, IPv4 and IPv6. Empty or absent allows every address; a caller outside the list gets `403 ip_not_allowed`.
- Access: a key has full access, which covers every product and any product added later, or custom access, which covers only what it was granted, product by product. Verify: none, check only, or send and check. Notifications: none, read only, or send. Whatever its access, a key is a server-side secret: never put one in a browser or a mobile app.
- The scopes behind those levels: `verify:check` calls `POST /v1/verify/check` and `GET /v1/verify/{id}`. `verify:send` calls `POST /v1/verify/start`, `POST /v1/verify/{id}/resend` and `POST /v1/verify/{id}/cancel`, and includes `verify:check`. `notifications:read` calls `GET /v1/whatsapp/messages/{id}`, `GET /v1/whatsapp/templates` and `GET /v1/whatsapp/templates/{template_id}`. `notifications:send` calls `POST /v1/whatsapp/messages`, and includes `notifications:read`. One more scope is not a level of that page: `notifications:templates` calls `POST /v1/whatsapp/templates`, `PATCH /v1/whatsapp/templates/{template_id}` and `DELETE /v1/whatsapp/templates/{template_id}`, and includes `notifications:read`. No key made on the API keys page holds it, full access included: those three calls answer such a key `403 insufficient_scope`. A connected app's key holds it.
- A call outside the key's access answers `403` with the body `{"error":"insufficient_scope"}`, and the response header names the scope the call needs: `WWW-Authenticate: Bearer error="insufficient_scope", scope="verify:send"`. It is judged right after the key is recognised: after `unauthorized`, `key_expired` and `ip_not_allowed`, before `account_not_active` and the request's shape.
- A check-only key created before access was set product by product works as it always did: it holds `verify:check` and `notifications:read`, so it checks codes and reads statuses and templates on both products, and sends on neither.
- A test key cannot be granted Notifications: WhatsApp has no sandbox, and `/v1/whatsapp/*` refuses every test key.
- Email: none, read only, or send. `email:read` calls `GET /v1/email/messages/{id}`. `email:send` calls `POST /v1/email/messages`, and includes `email:read`. A test key cannot be granted Email: there is no e-mail sandbox, and `/v1/email/*` refuses every test key.
- Set at creation on the application's API keys page in the client console (Create a key). A key's access cannot be changed afterwards: create a new key with the access you need. A partner rotating its key can set an expiry and an IP allowlist too; a partner key always has full access and cannot be narrowed.
Sandbox
Create a tk_test_ key in the dashboard before application approval. It uses the same endpoints and request formats as a live key.
tk_test_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6- Created by you, before application approval: on the application's API keys page (Create a key, type Test), or made for you by the first send from Verify → Test in the console.
- Calls the exact same endpoints with the exact same request/response shapes as a live key.
- Sends real messages, but only to one number: the phone the account owner verified at signup. No other destination can be added.
- Every message carries an unremovable [TEST]/[تجريبي] stamp.
- Charged like any other send, from the same prepaid balance, and capped at 10 lifetime sends per application so testing cannot consume the whole welcome credit.
- WhatsApp has no sandbox: `/v1/whatsapp/*` refuses every test key, with `403 live_key_required` (or `403 insufficient_scope` for a test key with custom access, which never holds Notifications).
Autofill-ready messages
The autofill domain comes from your reviewed application website. Add an Android app hash for Google SMS Retriever, alongside iOS AutoFill and WebOTP support.
- The domain is never typed: `autofill_domain` is derived from the application's reviewed website (its registrable domain, so subdomains are covered) and is read-only in every response. `autofill_domain_enabled` switches its line on or off; `android_app_hash` (11 characters) is set per application, from the dashboard, or through the partner API on create and on the verify-settings patch.
- The `@domain #code` line adds about 25 characters. Arabic SMS is billed per 70-character segment, so the line can turn one segment into two: the console preview shows the segment count, and the switch exists for exactly that reason.
- The SMS gains, after the human-readable text: the Android app-hash line (when a hash is set and Android is targeted), then `@domain #code` as the very last line (when a domain is set and the web is targeted).
- iOS AutoFill and the WebOTP API read only the last line of the message, which is why the domain line always comes last, after the app hash.
- Google's SMS Retriever only needs the hash present anywhere in the message, and the whole message under 140 bytes. GSM-7 text is 1 byte per character; anything outside that alphabet (most Arabic) is 2 bytes per character.
- The optional `autofill` field on `start` (`web`, `android` or `none`) picks which line(s) go out on that one send; omit it to send every line the application has configured.
- The sandbox stamp, when present, always comes first, before any autofill line.
- Out of scope for WhatsApp: its message is a fixed Meta template with no room for either line.
Example message, under 140 bytes
Your Flower Shop verification code is: 482913 FA+9qCX9VSu @shop.example.com #482913
Endpoints
/v1/verify/startStart a verification
Sends a one-time code to a destination and returns an id to check it against.
Parameters
tostringRequiredThe destination. Any of the accepted Saudi mobile formats below.
phonestringOptionalDeprecated alias for `to`, kept for pre-multichannel integrations. Only used when `to` is absent or blank; `to` always wins when both are present. New integrations should use `to`.
channelstringOptionalDelivery channel. Defaults to "sms" when absent or blank. "whatsapp" answers `unsupported_channel` until Tawked opens WhatsApp for codes to every account.
langstringOptionalMessage language. Only the literal value "en" selects English; anything else (including missing) defaults to Arabic.
applicationstringPartner keyPartner keys only: which of your provisioned applications is sending. The legacy field name `service` is accepted identically.
referencestringOptionalYour own reference (order id, request id, …), up to 64 printable characters. Echoed back on `start` and on `GET /v1/verify/{id}`, and searchable in the dashboard's verifications list.
client_ipstringOptionalThe end user's IP address. Enables the per-IP protection guard for this application when one is configured. Never inferred from the request, always explicit.
autofillstringOptional`web`, `android` or `none`: which autofill line(s) to include on this send. `web` sends only the `@domain #code` line, `android` only the app-hash line, `none` sends neither. Omit it to send every line the application has configured.
Headers
Idempotency-KeystringOptionalA 1–128 printable-ASCII-character key you generate, scoped to your API key (and, for a partner key, to the named application too). Replaying the same key returns the original `201` response unchanged, with the response header `Idempotent-Replayed: true`, and nothing is sent or charged again. A send that fails releases the key so a retry can go through.
Response
{
"id": "b7e5c2b0-9c1a-4e2f-8f2a-3a6b0e9d1c44",
"status": "pending",
"expires_at": "2026-09-01T12:34:56.789Z"
}Request example
curl -X POST https://tawked.com/v1/verify/start \
-H "Authorization: Bearer tk_live_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"to": "0551234567",
"channel": "sms",
"lang": "ar"
}'/v1/verify/checkCheck a code
Verifies the code the end user typed against the id from /verify/start.
Parameters
idstringRequiredThe id returned by /verify/start.
codestringRequiredThe code the end user typed. Compared exactly as sent, never trimmed or normalized, so a stray space will not match.
applicationstringPartner keyPartner keys only, same meaning as on /verify/start. A key can only check a code started under the same application.
Response
{
"verified": true,
"status": "verified"
}{
"verified": false,
"status": "invalid_code",
"attempts_remaining": 2
}{
"verified": false,
"status": "too_many_attempts",
"attempts_remaining": 0
}{
"verified": false,
"status": "failed"
}{
"verified": false,
"status": "expired"
}{
"verified": false,
"status": "canceled"
}{
"error": "not_found"
}Request example
curl -X POST https://tawked.com/v1/verify/check \
-H "Authorization: Bearer tk_live_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"id": "b7e5c2b0-9c1a-4e2f-8f2a-3a6b0e9d1c44",
"code": "482913"
}'/v1/verify/{id}Get a verification
Reads the current state of a verification, meaning its status, channel, destination, and the attempts and resends left, without consuming an attempt.
Parameters
applicationstringPartner keyPartner keys only: passed as a query parameter (`?application=…`) since a GET request has no body. Same meaning as on `start`; the legacy name `service` is accepted identically.
Response
{
"id": "b7e5c2b0-9c1a-4e2f-8f2a-3a6b0e9d1c44",
"status": "pending",
"channel": "sms",
"to": "+966551234567",
"reference": "order-42",
"attempts_remaining": 3,
"resends_remaining": 3,
"expires_at": "2026-09-01T12:34:56.789Z",
"verified_at": null,
"created_at": "2026-09-01T12:29:56.789Z"
}{
"error": "not_found"
}Request example
curl https://tawked.com/v1/verify/b7e5c2b0-9c1a-4e2f-8f2a-3a6b0e9d1c44 \
-H "Authorization: Bearer tk_live_xxxxxxxxxxxxxxxxxxxx"/v1/verify/{id}/resendResend a code
Sends a new code on the same id: the previous code stops working, attempts and the expiry reset, and the send is charged and rate-limited like any other, up to 3 resends per verification.
Parameters
applicationstringPartner keyPartner keys only, same meaning as on `start`.
Response
{
"id": "b7e5c2b0-9c1a-4e2f-8f2a-3a6b0e9d1c44",
"status": "pending",
"expires_at": "2026-09-01T12:39:56.789Z",
"resends_remaining": 2
}{
"error": "not_resendable",
"status": "verified"
}{
"error": "resend_limit_reached"
}Request example
curl -X POST https://tawked.com/v1/verify/b7e5c2b0-9c1a-4e2f-8f2a-3a6b0e9d1c44/resend \
-H "Authorization: Bearer tk_live_xxxxxxxxxxxxxxxxxxxx"/v1/verify/{id}/cancelCancel a verification
Cancels a pending verification. A later `check` on it answers `{ "verified": false, "status": "canceled" }`.
Parameters
applicationstringPartner keyPartner keys only, same meaning as on `start`.
Response
{
"id": "b7e5c2b0-9c1a-4e2f-8f2a-3a6b0e9d1c44",
"status": "canceled"
}{
"error": "not_cancelable",
"status": "verified"
}Request example
curl -X POST https://tawked.com/v1/verify/b7e5c2b0-9c1a-4e2f-8f2a-3a6b0e9d1c44/cancel \
-H "Authorization: Bearer tk_live_xxxxxxxxxxxxxxxxxxxx"WhatsApp messages
Send an approved WhatsApp template through your own number, connected to your application, with the application's live key. Tawked validates the template against the number's live template list, charges the fee, sends through Meta on your behalf, records the message, mirrors it into the Tawked Chat thread, and tracks delivery and read. Your code never holds a Meta token. An application in review or approved connects its WhatsApp Business Account from the dashboard.
Getting started
Four steps from nothing to the first message, three of them in the dashboard.
- 01
Connect your number
Dashboard → Applications → your application, in review or approved → Notifications → Connect WhatsApp; the owner or an admin connects it. Meta opens a window where you pick, or create, the business and the number; Tawked registers it and sets up the inbox here. You keep full access in WhatsApp Manager and pay Meta directly for messages.
- 02
Create a template
On the application's Templates page, from Meta's template library, or in WhatsApp Manager. Meta reviews it in up to 24 hours; only an approved template can be sent. Utility, marketing and authentication templates all work here; an authentication template sends a one-time code from your own number.
- 03
Use the application's live key
Dashboard → Applications → your application → Keys. WhatsApp sends need a tk_live_ key of the application the number is connected to, with full access or with Notifications set to send; a tk_test_ key is refused, there is no WhatsApp sandbox.
- 04
List, then send
GET /v1/whatsapp/templates shows every template with the placeholders it expects. POST /v1/whatsapp/messages with to, template and params sends one; the reply is a 202 with an id. Read the status back with GET /v1/whatsapp/messages/{id}, or receive message.* webhooks.
/v1/whatsapp/messagesSend a template message
Sends an approved template to a Saudi mobile through the number attached to the application, and answers `202` with an id to track it. The fee, if any, is for the send; a failed send refunds it.
Parameters
tostringRequiredThe destination: a Saudi mobile in any of the accepted formats, stored as E.164.
templatestringRequiredThe template name exactly as WhatsApp Manager lists it, 1 to 512 characters. It must be approved on the number's WhatsApp Business Account.
langstringOptionalThe template language as Meta lists it (`ar`, `en`, `en_US`). Required when the template exists in more than one language.
paramsobjectOptionalPlaceholder values for the body: the keys are the placeholder names of a named template (`{{name}}`), or `"1"`, `"2"` for a positional one (`{{1}}`). Every placeholder is required, extra keys are refused, values are strings of 1 to 1024 characters. An authentication template takes exactly `{ "code": "…" }`, 4 to 15 letters or digits, which fills its text and its button. `GET /v1/whatsapp/templates` lists the placeholders of each template.
headerobjectOptional`{ "text": "…" }`: the value of the text header's placeholder. Required when the template has one, refused otherwise; no newlines. For a template that opens with an image, `{ "image": { "link": "https://…" } }`: the image's public https link, at most 2048 characters, a JPG or PNG of at most 5 MB that Meta fetches when it sends; required for such a template, refused for any other. Video, document and location headers are not supported yet.
buttonsarrayOptional`[{ "index": 0, "parameter": "…" }]`: the dynamic suffix of each URL button, one entry per button that has a placeholder; `index` is the button's position among the template's buttons. Refused for an authentication template: `params.code` fills its button.
referencestringOptionalYour own reference (order id, event id), 1 to 64 printable characters. Echoed back, and searchable in the Messages log of the console.
Headers
Idempotency-KeystringOptionalA 1 to 128 printable-ASCII-character key you generate, scoped to your API key. Replaying the same key returns the original `202` response unchanged, with the response header `Idempotent-Replayed: true`: nothing is sent or charged again. A send that fails with `502` releases the key. Use one key per business event (`order-1042-received`) and retry a `502` or a network error with the same key.
Response
{
"id": "0f4c9c0e-6d2b-4b8a-9c3e-7a1d2e3f4a5b",
"status": "accepted",
"to": "+966550000000",
"template": "order_received",
"lang": "ar",
"reference": "order-30517",
"price_halalas": 0,
"created_at": "2026-09-06T12:00:00+03:00"
}{
"error": "unknown_template"
}{
"error": "insufficient_credits",
"message": "Insufficient credits",
"balance_halalas": 0,
"price_halalas": 9
}Request example
curl -X POST https://tawked.com/v1/whatsapp/messages \
-H "Authorization: Bearer tk_live_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-30517-received" \
-d '{
"to": "0550000000",
"template": "order_received",
"lang": "ar",
"params": {
"customer_name": "سارة",
"order_number": "30517"
},
"reference": "order-30517"
}'/v1/whatsapp/messages/{id}Get a message
Reads a message's current status and timestamps. Statuses from Meta are followed for 48 hours after the send; `error` is set when the message failed. A message belongs to the key's application; any other id answers `404`.
Parameters
Response
{
"id": "0f4c9c0e-6d2b-4b8a-9c3e-7a1d2e3f4a5b",
"status": "delivered",
"to": "+966550000000",
"template": "order_received_v1",
"lang": "ar",
"reference": "order-1042",
"price_halalas": 0,
"error": null,
"created_at": "2026-09-06T12:00:00+03:00",
"sent_at": "2026-09-06T12:00:01+03:00",
"delivered_at": "2026-09-06T12:00:04+03:00",
"read_at": null,
"failed_at": null
}{
"id": "0f4c9c0e-6d2b-4b8a-9c3e-7a1d2e3f4a5b",
"status": "failed",
"to": "+966550000000",
"template": "order_received_v1",
"lang": "ar",
"reference": "order-1042",
"price_halalas": 0,
"error": {
"code": "131026",
"message": "Message undeliverable"
},
"created_at": "2026-09-06T12:00:00+03:00",
"sent_at": null,
"delivered_at": null,
"read_at": null,
"failed_at": "2026-09-06T12:00:03+03:00"
}{
"error": "not_found"
}Request example
curl https://tawked.com/v1/whatsapp/messages/0f4c9c0e-6d2b-4b8a-9c3e-7a1d2e3f4a5b \
-H "Authorization: Bearer tk_live_xxxxxxxxxxxxxxxxxxxx"/v1/whatsapp/templatesList templates
Lists the templates of the number's WhatsApp Business Account as Meta holds them, with the placeholders each one expects, so your code can discover what to send without WhatsApp Manager. Each row carries Meta's `id`, which the template operations take, and `managed`: true only when the calling key is a connected app's and that app created the template through this application. Cached for 5 minutes.
Parameters
Response
{
"data": [
{
"id": "7000",
"name": "order_received_v1",
"language": "ar",
"category": "UTILITY",
"status": "APPROVED",
"placeholders": {
"kind": "named",
"header": [],
"body": [
"name",
"order"
],
"buttons": [
{
"index": 0,
"type": "url"
}
]
},
"managed": false
}
]
}Request example
curl https://tawked.com/v1/whatsapp/templates \
-H "Authorization: Bearer tk_live_xxxxxxxxxxxxxxxxxxxx"/v1/whatsapp/templates/{template_id}Get a template
Reads one template by its Meta `id`, in full: its status, quality and rejection reason, its components as Meta holds them, the sample values, the placeholders a send must fill, and `managed`. An id that is not on the number's WhatsApp Business Account answers `404`.
Parameters
Response
{
"id": "7000",
"name": "order_received_v1",
"language": "ar",
"category": "UTILITY",
"status": "APPROVED",
"quality": "GREEN",
"rejected_reason": null,
"components": [
{
"type": "HEADER",
"format": "TEXT",
"text": "طلبك {{1}}",
"buttons": []
},
{
"type": "BODY",
"format": null,
"text": "استلمنا طلبك رقم {{1}} بقيمة {{2}} ريال.",
"buttons": []
},
{
"type": "BUTTONS",
"format": null,
"text": null,
"buttons": [
{
"type": "URL",
"text": "تتبع الطلب",
"url": "https://flowers.example/orders/{{1}}",
"phone_number": null
}
]
}
],
"examples": {
"header:1": "1042",
"body:1": "1042",
"body:2": "250",
"button:0": "1042"
},
"placeholders": {
"kind": "positional",
"header": [
"1"
],
"body": [
"1",
"2"
],
"buttons": [
{
"index": 0,
"type": "url"
}
]
},
"managed": false
}{
"error": "not_found"
}Request example
curl https://tawked.com/v1/whatsapp/templates/7000 \
-H "Authorization: Bearer tk_live_xxxxxxxxxxxxxxxxxxxx"/v1/whatsapp/templatesCreate a template
For a connected app's key alone: an application's own key, full access included, answers `403 insufficient_scope`, and writes its templates in the console. Submits a text template to Meta's review under the rules of the console's editor, and answers Meta's `id`, the category as Meta filed it and the `status` (`PENDING` at first, usually). Text only: any field outside the ones below is ignored, so a template that opens with an image is created in the console. A refused field answers `400 invalid_request` with `fields` naming it and `errors` giving each field's reasons; what Meta refuses after that answers `422 template_refused` with Meta's own sentence.
Parameters
namestringRequiredLowercase letters, digits and underscores, up to 512 characters. Meta fixes it at creation. A connected app's key may not take a name that starts `salla_`, `zid_` or `wf_`, or one of Tawked's own template names: `errors.name` is then `nameReserved`.
languagestringRequiredOne of `ar`, `en`, `en_US`, `en_GB`, `ur`, `hi`, `bn`, `tl`, `id`, `fr`. Fixed at creation.
categorystringRequired`UTILITY`. A connected app's key writes utility templates only: `MARKETING` is refused with `422 template_category_not_allowed`. An authentication template is created in the console.
headerstringOptionalA text header: one line, up to 60 characters, no emoji and no asterisk, at most one placeholder.
bodystringRequiredUp to 1024 characters. Placeholders are all named (`{{name}}`, lowercase) or all numbered (`{{1}}`, `{{2}}` in order), never at the very start or end of the text.
footerstringOptionalOne line, up to 60 characters, no placeholders.
buttonsarrayOptionalUp to 10 objects, each with a `text` of up to 25 characters: `{ "type": "QUICK_REPLY", "text" }`, `{ "type": "URL", "text", "url" }` (https, optionally ending in `{{1}}`; two at most) or `{ "type": "PHONE_NUMBER", "text", "phone_number" }` (E.164; one at most). Quick replies sit together, before or after the other buttons.
examplesobjectOptionalOne sample value per placeholder, keyed `header:<key>`, `body:<key>` and `button:<index>`. Required whenever the template has placeholders: Meta reviews with them.
Headers
Idempotency-KeystringOptionalA 1 to 128 printable-ASCII-character key you generate, scoped to your API key. Replaying it with the same request answers the original 2xx unchanged, with the response header `Idempotent-Replayed: true`, and runs nothing again; the same key with another request answers `409 idempotency_key_reused`, and a request still in flight `409 idempotency_in_progress`. A refused request is never stored, so a corrected retry under the same key runs. Keys expire after 24 hours.
Response
{
"id": "7100",
"name": "order_shipped_v1",
"language": "ar",
"category": "UTILITY",
"status": "PENDING"
}{
"error": "invalid_request",
"fields": [
"body",
"examples.header:order"
],
"errors": {
"body": [
"placeholderEdges"
],
"examples.header:order": [
"exampleRequired"
]
}
}{
"error": "template_refused",
"message": "Meta error 100: Template name already exists"
}Request example
curl -X POST https://tawked.com/v1/whatsapp/templates \
-H "Authorization: Bearer tk_live_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_shipped_v1-ar" \
-d '{
"name": "order_shipped_v1",
"language": "ar",
"category": "UTILITY",
"header": "طلبك {{order}}",
"body": "طلبك {{order}} في الطريق، ويصلك خلال {{days}} أيام.",
"footer": "متجر الورد",
"buttons": [
{
"type": "URL",
"text": "تتبع الشحنة",
"url": "https://flowers.example/track/{{1}}"
}
],
"examples": {
"header:order": "1042",
"body:order": "1042",
"body:days": "3",
"button:0": "1042"
}
}'/v1/whatsapp/templates/{template_id}Update a template
For a connected app's key alone: an application's own key, full access included, answers `403 insufficient_scope`, and writes its templates in the console. Replaces a text template's content on Meta. Send the whole new content: the body of a creation without `name` and `language`, which Meta fixed at creation and which are ignored here. Meta reviews the change again. Only an `APPROVED`, `REJECTED` or `PAUSED` template can be edited; anything else answers `409 template_not_editable` with a `status`: the template's own, `META_SAMPLE` for one of Meta's samples, or `MEDIA_HEADER` for a template that opens with an image or other media, which is edited in the console. An authentication template answers `422 template_category_not_allowed`. Answers the template as it stands after the edit.
Parameters
categorystringRequired`UTILITY`. A connected app's key writes utility templates only: `MARKETING` is refused with `422 template_category_not_allowed`. An authentication template is created in the console.
headerstringOptionalA text header: one line, up to 60 characters, no emoji and no asterisk, at most one placeholder.
bodystringRequiredUp to 1024 characters. Placeholders are all named (`{{name}}`, lowercase) or all numbered (`{{1}}`, `{{2}}` in order), never at the very start or end of the text.
footerstringOptionalOne line, up to 60 characters, no placeholders.
buttonsarrayOptionalUp to 10 objects, each with a `text` of up to 25 characters: `{ "type": "QUICK_REPLY", "text" }`, `{ "type": "URL", "text", "url" }` (https, optionally ending in `{{1}}`; two at most) or `{ "type": "PHONE_NUMBER", "text", "phone_number" }` (E.164; one at most). Quick replies sit together, before or after the other buttons.
examplesobjectOptionalOne sample value per placeholder, keyed `header:<key>`, `body:<key>` and `button:<index>`. Required whenever the template has placeholders: Meta reviews with them.
Headers
Idempotency-KeystringOptionalA 1 to 128 printable-ASCII-character key you generate, scoped to your API key. Replaying it with the same request answers the original 2xx unchanged, with the response header `Idempotent-Replayed: true`, and runs nothing again; the same key with another request answers `409 idempotency_key_reused`, and a request still in flight `409 idempotency_in_progress`. A refused request is never stored, so a corrected retry under the same key runs. Keys expire after 24 hours.
Response
{
"id": "7000",
"name": "order_received_v1",
"language": "ar",
"category": "UTILITY",
"status": "PENDING",
"quality": null,
"rejected_reason": null,
"components": [
{
"type": "HEADER",
"format": "TEXT",
"text": "طلبك {{1}}",
"buttons": []
},
{
"type": "BODY",
"format": null,
"text": "استلمنا طلبك رقم {{1}} بقيمة {{2}} ريال، ونبلغك عند شحنه.",
"buttons": []
},
{
"type": "BUTTONS",
"format": null,
"text": null,
"buttons": [
{
"type": "URL",
"text": "تتبع الطلب",
"url": "https://flowers.example/orders/{{1}}",
"phone_number": null
}
]
}
],
"examples": {
"header:1": "1042",
"body:1": "1042",
"body:2": "250",
"button:0": "1042"
},
"placeholders": {
"kind": "positional",
"header": [
"1"
],
"body": [
"1",
"2"
],
"buttons": [
{
"index": 0,
"type": "url"
}
]
},
"managed": false
}{
"error": "template_not_editable",
"status": "PENDING"
}{
"error": "not_found"
}Request example
curl -X PATCH https://tawked.com/v1/whatsapp/templates/7000 \
-H "Authorization: Bearer tk_live_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_received_v1-ar-2" \
-d '{
"category": "UTILITY",
"header": "طلبك {{1}}",
"body": "استلمنا طلبك رقم {{1}} بقيمة {{2}} ريال، ونبلغك عند شحنه.",
"buttons": [
{
"type": "URL",
"text": "تتبع الطلب",
"url": "https://flowers.example/orders/{{1}}"
}
],
"examples": {
"header:1": "1042",
"body:1": "1042",
"body:2": "250",
"button:0": "1042"
}
}'/v1/whatsapp/templates/{template_id}Delete a template
For a connected app's key alone: an application's own key, full access included, answers `403 insufficient_scope`, and writes its templates in the console. Deletes that one language of the template on Meta. Refused while something still sends it: `409 template_in_use`, with `used_by` saying what. Meta's own samples cannot be deleted (`409 template_not_editable` with `status` `META_SAMPLE`).
Parameters
Headers
Idempotency-KeystringOptionalA 1 to 128 printable-ASCII-character key you generate, scoped to your API key. Replaying it with the same request answers the original 2xx unchanged, with the response header `Idempotent-Replayed: true`, and runs nothing again; the same key with another request answers `409 idempotency_key_reused`, and a request still in flight `409 idempotency_in_progress`. A refused request is never stored, so a corrected retry under the same key runs. Keys expire after 24 hours.
Response
{
"id": "7002",
"name": "promo_v1",
"language": "en",
"deleted": true
}{
"error": "template_in_use",
"used_by": [
"campaign",
"automation"
]
}{
"error": "not_found"
}Request example
curl -X DELETE https://tawked.com/v1/whatsapp/templates/7002 \
-H "Authorization: Bearer tk_live_xxxxxxxxxxxxxxxxxxxx"Message statuses
acceptedsentdeliveredreadfailedfailed can follow any of the first three, and it refunds the fee.
- `accepted`: Tawked recorded the message and Meta took it. This is the `202`.
- `sent`, `delivered`, `read`: Meta's statuses, applied in that order and never backwards. They reach Tawked through the status sync, which follows each message for 48 hours after the send.
- `failed`: possible from any of the first three (an unreachable number, a paused template, a closed messaging window). `error` carries Meta's code and message.
- The fee is for the send, never for delivery: `read` costs nothing more, and `failed` refunds it once. A message stops being tracked 48 hours after the send and keeps its last status.
WhatsApp webhooks
message.sentmessage.deliveredmessage.readmessage.failed- One event per status change, to the application's webhook URL, signed and retried exactly like `verification.*`: the same `tawked-timestamp`, `tawked-signature` and `tawked-event` headers, the same HMAC over `"{timestamp}.{raw body}"`, the same retry schedule, the same delivery log in the console.
- The same envelope as `verification.*`: `event`, `data` and `sent_at`. The message's fields sit under `data`, with `occurred_at` the time of the status the event announces and `error` null unless the event is `message.failed` (then `{ "code", "message" }`). A test event from the console is always `verification.verified`, so no `message.*` event carries `test`.
- Delivered by the status sync, which follows each message for 48 hours after the send. Only the status reached is announced: a message that jumps straight to `read` gets one `message.read`, not the rungs in between.
- `to` is masked the way the console shows it. Keep your own copy of the destination next to the returned `id` or your `reference`.
Example payload
{
"event": "message.delivered",
"data": {
"id": "0f4c9c0e-6d2b-4b8a-9c3e-7a1d2e3f4a5b",
"status": "delivered",
"application_id": "c2a10f3e-8b7a-4d2e-9c1a-1a2b3c4d5e6f",
"to": "+9665•• ••• 000",
"template": "order_received_v1",
"lang": "ar",
"reference": "order-1042",
"price_halalas": 0,
"error": null,
"occurred_at": "2026-09-06T09:00:04.512Z",
"created_at": "2026-09-06T09:00:01.087Z"
},
"sent_at": "1788685205201"
}WhatsApp errors
After the checks every endpoint shares: rate limit, authentication, account state and request shape, in this order.
| Status | error | Meaning |
|---|---|---|
| 403 | live_key_required | A sandbox (tk_test_) key. WhatsApp has no sandbox yet; use the application's live key. |
| 403 | whatsapp_not_enabled | WhatsApp is turned off for this application. Message support if you think this is a mistake. |
| 409 | no_whatsapp_number | No WhatsApp number is connected to this application yet. Connect one from the Number section of the application's Notifications tab in the dashboard (Getting started, step 1). |
| 409 | number_disconnected | The number is attached but not connected on Meta's side right now (its webhook is not claimed, or Meta reports a status other than CONNECTED). The console's Notifications page shows the number's health. |
| 422 | invalid_destination | The destination does not match any accepted Saudi mobile format. WhatsApp messages go to Saudi mobiles only. |
| 422 | unknown_template | No template with that name (and language, when given) on the number's WhatsApp Business Account. `GET /v1/whatsapp/templates` lists what exists. |
| 422 | template_not_approved | The template exists but Meta has not approved it; `message` carries Meta's status (PENDING, REJECTED, PAUSED, …). |
| 422 | template_category_not_allowed | An AUTHENTICATION template sent with a partner key. Partner keys send utility and marketing templates; the application's own live key sends authentication ones. Also an authentication template in `PATCH /v1/whatsapp/templates/{template_id}` (its text is Meta's), and a connected app's key on any category but UTILITY, in a send or in a template it creates or edits. |
| 422 | consent_opted_out | A MARKETING template to a recipient who asked this application to stop its marketing messages (they wrote STOP to the number, or turned off its offers in WhatsApp). Nothing was sent, recorded or charged. Utility and authentication templates still reach them. |
| 422 | unsupported_template | The template needs something this endpoint does not carry yet: a video, document or location header (an image header is sent with `header.image.link`), a button or component that takes a non-text parameter (the copy-code button of a marketing coupon, flow, catalog, carousel, limited-time offer), or named and positional placeholders mixed in the text. `message` says which. |
| 422 | invalid_params | A placeholder value is missing, empty, unexpected, longer than 1024 characters or contains a newline, a tab or another control character (Meta refuses those), the template exists in several languages and `lang` was not given, an authentication template got `buttons` or a `code` that is not 4 to 15 letters or digits, or a template that opens with an image got no `header.image.link` (or one that is not https, or another template got one). `message` names the placeholder. |
| 402 | insufficient_credits | The account's prepaid balance is below the WhatsApp message fee. The body adds `balance_halalas` and `price_halalas`; nothing was sent or recorded. |
| 502 | send_failed | Meta refused the send or is unreachable. The body adds `provider_code` (Meta's error code) and `message`; the fee is refunded and the message is recorded as failed. |
| 403 | template_not_managed | A connected app's key on a template that app did not create for this application. Such a key edits and deletes only the templates it created, the ones `managed` is true on. |
| 409 | template_not_editable | The template cannot be changed over the API as it stands; `status` says why. On an edit: the template's status when it is not `APPROVED`, `REJECTED` or `PAUSED`, or `MEDIA_HEADER` for a template that opens with an image or other media, which is edited in the console. On an edit or a delete: `META_SAMPLE` for one of Meta's own samples. |
| 409 | template_in_use | Something still sends the template, so it is neither edited nor deleted. `used_by` lists what, from `campaign` (a campaign that has not finished), `automation` (an automation that is on) and `store_notice` (a store integration's notice that is on), in that order. The business stops it or gives it another template in the console first. |
| 409 | idempotency_in_progress | A request with this `Idempotency-Key` is still running. Wait for its answer, or retry in a moment. |
| 409 | idempotency_key_reused | This `Idempotency-Key` was already used for a different request. Use one key per change. |
| 422 | template_refused | Meta refused the template after Tawked's own checks passed; `message` is Meta's own sentence (a name already in use, a text it will not review). Also a connected app's key that already holds as many templates as it may: `message` says so. |
| 503 | templates_unavailable | Meta did not answer the read of the number's template list, which the template endpoints and a send need before anything happens, or did not answer a template write. Nothing was sent, changed or charged; retry shortly. |
Every error the API can return
| Status | error | Meaning |
|---|---|---|
| 429 | too_many_requests | Per-key rate limit exceeded (default 120 requests/minute, fixed 60s window). Checked before authentication. |
| 401 | unauthorized | Missing, unknown, revoked, or mode-mismatched key; or an unknown/suspended partner. |
| 400 | service_required | Partner key used with no `application` (or `service`) in the request body. |
| 404 | service_not_found | Partner key, but `application`/`service` does not match any of that partner's provisioned applications. |
| 403 | account_not_active | The application is not approved yet, the account is banned, or a partner disabled this application. All four causes share this one response. |
| 400 | invalid_json | The request body is not valid JSON. |
| 400 | invalid_request | start: `to` (and `phone`) both missing or blank. check: `id` or `code` missing. |
| 422 | unsupported_channel | Unknown channel name, or a known channel that is not live: WhatsApp until Tawked opens it for every account, or email: codes are not sent by email (Tawked Email sends a business's own mail, https://tawked.com/en/email). |
| 422 | sandbox_unsupported_channel | A sandbox (tk_test_) key on a channel that cannot carry the [TEST] stamp (WhatsApp's fixed Meta template has no room for it). |
| 422 | invalid_destination | The destination does not match any accepted Saudi mobile format. |
| 422 | sandbox_unverified_destination | Sandbox key, and the destination is not the phone the account owner verified at signup. |
| 429 | sandbox_quota_exceeded | The application's lifetime sandbox send cap (10) is reached. |
| 429 | rate_limited | The per-destination hourly cap is reached. Distinct from too_many_requests above: same HTTP status, different `error` string. |
| 402 | insufficient_credits | The account's prepaid balance is below the channel price. |
| 502 | send_failed | The delivery provider call threw. The charge is refunded automatically. |
| 404 | not_found | Unknown id, or an id that belongs to a different account or application than the calling key's. |
| 500 | internal_error | An unhandled server error. Quote the X-Request-Id header if you report it. |
| 401 | key_expired | The key's `expires_at` has passed. Checked right after the key is recognised, before the IP allowlist or scope. |
| 403 | ip_not_allowed | The caller's IP does not match the key's `ip_allowlist` (exact IPs and/or CIDR ranges, IPv4 and IPv6). |
| 403 | insufficient_scope | The key was not granted this call: a custom key without this product, or with a level below the call, such as a key that checks where the call sends. The response header `WWW-Authenticate` names the scope the call needs. Judged right after the key is recognised, before `account_not_active` and the request's shape. |
| 403 | application_paused | The application is auto-paused by the pumping guard (a spike in distinct new destinations within an hour). The owner or an admin must resume it. |
| 429 | ip_rate_limited | The caller passed `client_ip`, and that address sent more starts in the last hour than the application's `max_per_ip_per_hour` allows. |
| 429 | spend_cap_reached | Today's charges (Riyadh calendar day) plus this send would pass the application's `daily_spend_cap_halalas`. |
| 409 | not_resendable | The verification is already in a terminal state (verified, failed, expired or canceled). The response includes the current `status`. |
| 409 | not_cancelable | The verification is already in a terminal state. The response includes the current `status`. |
| 429 | resend_limit_reached | This verification already used its 3 resends. |
| 403 | live_key_required | A sandbox (tk_test_) key. WhatsApp has no sandbox yet; use the application's live key. |
| 403 | whatsapp_not_enabled | WhatsApp is turned off for this application. Message support if you think this is a mistake. |
| 409 | no_whatsapp_number | No WhatsApp number is connected to this application yet. Connect one from the Number section of the application's Notifications tab in the dashboard (Getting started, step 1). |
| 409 | number_disconnected | The number is attached but not connected on Meta's side right now (its webhook is not claimed, or Meta reports a status other than CONNECTED). The console's Notifications page shows the number's health. |
| 422 | unknown_template | No template with that name (and language, when given) on the number's WhatsApp Business Account. `GET /v1/whatsapp/templates` lists what exists. |
| 422 | template_not_approved | The template exists but Meta has not approved it; `message` carries Meta's status (PENDING, REJECTED, PAUSED, …). |
| 422 | template_category_not_allowed | An AUTHENTICATION template sent with a partner key. Partner keys send utility and marketing templates; the application's own live key sends authentication ones. Also an authentication template in `PATCH /v1/whatsapp/templates/{template_id}` (its text is Meta's), and a connected app's key on any category but UTILITY, in a send or in a template it creates or edits. |
| 422 | consent_opted_out | A MARKETING template to a recipient who asked this application to stop its marketing messages (they wrote STOP to the number, or turned off its offers in WhatsApp). Nothing was sent, recorded or charged. Utility and authentication templates still reach them. |
| 422 | unsupported_template | The template needs something this endpoint does not carry yet: a video, document or location header (an image header is sent with `header.image.link`), a button or component that takes a non-text parameter (the copy-code button of a marketing coupon, flow, catalog, carousel, limited-time offer), or named and positional placeholders mixed in the text. `message` says which. |
| 422 | invalid_params | A placeholder value is missing, empty, unexpected, longer than 1024 characters or contains a newline, a tab or another control character (Meta refuses those), the template exists in several languages and `lang` was not given, an authentication template got `buttons` or a `code` that is not 4 to 15 letters or digits, or a template that opens with an image got no `header.image.link` (or one that is not https, or another template got one). `message` names the placeholder. |
| 403 | template_not_managed | A connected app's key on a template that app did not create for this application. Such a key edits and deletes only the templates it created, the ones `managed` is true on. |
| 409 | template_not_editable | The template cannot be changed over the API as it stands; `status` says why. On an edit: the template's status when it is not `APPROVED`, `REJECTED` or `PAUSED`, or `MEDIA_HEADER` for a template that opens with an image or other media, which is edited in the console. On an edit or a delete: `META_SAMPLE` for one of Meta's own samples. |
| 409 | template_in_use | Something still sends the template, so it is neither edited nor deleted. `used_by` lists what, from `campaign` (a campaign that has not finished), `automation` (an automation that is on) and `store_notice` (a store integration's notice that is on), in that order. The business stops it or gives it another template in the console first. |
| 409 | idempotency_in_progress | A request with this `Idempotency-Key` is still running. Wait for its answer, or retry in a moment. |
| 409 | idempotency_key_reused | This `Idempotency-Key` was already used for a different request. Use one key per change. |
| 422 | template_refused | Meta refused the template after Tawked's own checks passed; `message` is Meta's own sentence (a name already in use, a text it will not review). Also a connected app's key that already holds as many templates as it may: `message` says so. |
| 503 | templates_unavailable | Meta did not answer the read of the number's template list, which the template endpoints and a send need before anything happens, or did not answer a template write. Nothing was sent, changed or charged; retry shortly. |
Rate limits
120 requests per minute per key by default, in a fixed 60-second window. It fails open on a cache error: defense in depth on top of your balance and the per-destination cap, never the primary control.
Request id
Every response carries an X-Request-Id header. Quote it if you write to support about a specific call.
Client webhooks
Configure a URL and a signing secret per application, and get a signed request for every outcome, with no polling.
verification.verifiedverification.failedverification.expired- Configured per application on its Webhooks page: a URL and a secret, revealed on demand and rotatable. An application a partner provisioned has no webhook of its own: its verification outcomes go to the partner's event log and, when the partner subscribed to them, to the partner webhook, with the partner's `external_id`.
- `verification.verified` and `verification.failed` fire from `check`; `verification.expired` fires from the background expiry sweep, and from any `check` or `GET` that discovers the expiry itself.
- Every delivery carries `tawked-timestamp`, `tawked-signature` (hex HMAC-SHA256 of `"{timestamp}.{raw body}"` with your secret) and `tawked-event`. Verify the signature against the raw request body before trusting any payload.
- Delivery retries automatically with backoff on anything but a 2xx response, with the same job and schedule as partner webhooks; the exact retry count is below. The delivery log in the console shows every attempt.
- The URL must be https on a public host, and your endpoint must answer itself: a redirect is not followed and counts as a failed attempt.
- A test event sent from the console's Webhooks page is one sample `verification.verified` whose envelope also carries `"test": true`, with made-up values under `data`: an id of zeros, the reference `test-event`, the mode `test`. No real event carries `test`, so skip your own side effects when it is there.
- An application with WhatsApp also receives `message.sent`, `message.delivered`, `message.read` and `message.failed` at the same URL, in the same envelope (see WhatsApp messages).
Example payload
{
"event": "verification.verified",
"data": {
"id": "b7e5c2b0-9c1a-4e2f-8f2a-3a6b0e9d1c44",
"status": "verified",
"application_id": "c2a10f3e-8b7a-4d2e-9c1a-1a2b3c4d5e6f",
"reference": "order-42",
"channel": "sms",
"to": "+966551234567",
"mode": "live",
"attempts": 1,
"expires_at": "2026-09-01T12:34:56.789Z",
"verified_at": "2026-09-01T12:31:02.145Z",
"created_at": "2026-09-01T12:29:56.789Z"
},
"sent_at": "1788265862201"
}Verify the signature in Node.js
const [timestamp, signature] = [
req.headers["tawked-timestamp"],
req.headers["tawked-signature"],
];
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) throw new Error("bad signature");Retried on anything but a 2xx response, up to 5 attempts, backing off by minutes: 1, 5, 30, 120, 360.
Partner API
Reseller platforms, like Salla or Zid apps, use a single tk_partner_ key to provision many stores over /v1/partner/applications, with webhooks for review and balance events.
Partner guideThis reference is built for machines too, as raw Markdown: /llms.txt · /llms-full.txt