WhatsApp OTP
Takeed sends login codes over WhatsApp from a verified business number. Your app keeps its own users: Takeed delivers the code and, if you want, checks it too. Your first company comes with 5 free codes.
On this page
482913 is your verification code. For your security, do not share this code.
This code expires in 5 minutes.
Before you start#
- Create an account, enter your company name, then add an app in the dashboard.
- Open Settings → OTP settings → API authorization and create a key. This is the secret used
in
Authorization: Key <key>; it is shown in full once. With this application key,targetis optional unless you want to select one branch. - In Message credit, transfer OTP credit from the company wallet to this app. A new app starts at zero and cannot send until it is funded.
- Keep the key on your server. Never put it in an app or a web page.
Deliver mode (default)#
Your backend makes the code and checks it; Takeed only delivers it.
curl https://takeed-iq.com/api/v1/otp/send \
-H "Authorization: Key $TAKEED_KEY" \
-H "Content-Type: application/json" \
-d '{"phone": "+9647701234567", "code": "482913", "user_ip": "203.0.113.7"}'Takeed answers 202 Accepted straight away and sends in the background:
{
"id": "01JAB3K9Q7X2M4N8P6R5T0VWYZ",
"mode": "deliver",
"status": "queued",
"phone": "+964•••••••567",
"target": "shop",
"verified": false,
"expires_at": null,
"error": null,
"created_at": "2026-10-15T09:41:00+00:00",
"sent_at": null,
"delivered_at": null,
"read_at": null,
"verified_at": null
}The code itself is never stored. Keep the id to follow the code.
API timestamps are ISO 8601 in UTC (+00:00) so integrations are unambiguous. The dashboard
displays them in Baghdad time (Asia/Baghdad).
Managed mode#
Takeed makes the code (6 digits, valid 5 minutes, 5 attempts by default), sends it and checks it. Turn it on in the dashboard: open the app, then the OTP tab, then Managed mode.
-
Send without
code:curl https://takeed-iq.com/api/v1/otp/send \ -H "Authorization: Key $TAKEED_KEY" \ -H "Content-Type: application/json" \ -d '{"phone": "+9647701234567", "user_ip": "203.0.113.7"}' -
The
202response has"mode": "managed", anidand anexpires_at. Keep theidwith the sign-in attempt. -
When the user types the code, send it with the
id:curl https://takeed-iq.com/api/v1/otp/verify \ -H "Authorization: Key $TAKEED_KEY" \ -H "Content-Type: application/json" \ -d '{"id": "01JAB3K9Q7X2M4N8P6R5T0VWYZ", "code": "482913"}'
A right code answers 200 with "status": "verified". Otherwise the answer names the reason:
code_invalid (wrong code), too_many_attempts, code_expired or already_verified. Every reason
is in Error codes.
Fields#
| Field | Required | Notes |
|---|---|---|
phone |
Yes | E.164, e.g. +9647701234567 |
user_ip |
Yes | The end user's public IP, forwarded by your backend. A private, reserved, or your own server's IP is refused (invalid_ip). |
target |
No | Use "app:branch" to select a branch. The application key already selects its app. |
code |
Deliver mode only | 4–10 letters or digits. Leave it out in managed mode. |
user_agent |
No | Stored with the code, to help trace abuse. |
device_id |
No | Stored with the code, to help trace abuse. |
Following a code#
Poll GET /api/v1/otp/{id}, or let Takeed call your webhook each time the code moves on:
| Event | State | Meaning |
|---|---|---|
otp.sent | sent | WhatsApp accepted the message. One credit is used. |
otp.delivered | delivered | The message reached the phone. |
otp.read | read | The user opened the chat. |
otp.failed | failed | WhatsApp could not deliver it; error says why. The credit is given back. |
otp.verified | verified | Managed mode: the user typed the right code. |
otp.expired | expired | Managed mode: the code ran out before it was used. |
Webhook#
Set the URL (HTTPS) in the dashboard: the app's General tab, Webhook URL. Create the signing
secret on the Security tab. Each event is a POST with this body, where data.otp has every
field of the API response above (a few are shown here):
{
"event": "otp.delivered",
"created_at": "2026-10-15T09:41:03+00:00",
"data": {
"otp": {"id": "01JAB3K9Q7X2M4N8P6R5T0VWYZ", "mode": "deliver", "status": "delivered", "target": "shop"}
}
}Each request carries a signature header in this form:
X-Takeed-Signature: t=<unix time>,v1=<HMAC-SHA256 of "<t>.<body>" with the webhook secret>Check it, and reject old timestamps:
// $header: the X-Takeed-Signature value. $body: the raw request body.
[$t, $v1] = sscanf($header, 't=%d,v1=%s');
$expected = hash_hmac('sha256', $t.'.'.$body, $secret);
$ok = hash_equals($expected, (string) $v1) && abs(time() - $t) < 300;Protection#
Takeed enforces, per app (adjustable on the app's OTP tab):
- per phone: 60 seconds between codes, 5 per hour;
- per IP: 30 per hour; after 10 refused requests in an hour the IP is blocked for the rest of the hour;
- per app: 100 per minute;
- a country-code allowlist.
A refused request answers with its reason (phone_rate_limited, ip_blocked, blocked_country…),
and the dashboard lists it with the same reason.
Your own wording#
By default the message is WhatsApp's authentication text shown above. It can say what you want
instead: your brand, the branch's name, the minutes left. Ask the Takeed team for wording for your
company, one app or one branch; it can use {code}, {name} (the branch, or the app) and
{minutes}. WhatsApp approves each wording before it is used, and the most specific one wins:
branch, then app, then company.
Credit#
Each code WhatsApp accepts uses one credit from the app's OTP balance, whether or not the user
verifies it. A provider failure refunds the same app. When the app balance is empty, requests are
refused with application_credit_exhausted (402); transfer more from Message
credit. If the company wallet has no distributable balance, its administrator must obtain credit
before funding the app. Notifications never use this balance.
Read the available OTP messages#
Your backend can read the application's current OTP balance with the same Authorization key used for sending:
curl https://takeed-iq.com/api/v1/otp/balance \
-H "Authorization: Key $TAKEED_KEY"{
"object": "otp_balance",
"app_id": "971ea6e3-2f83-4a5c-a501-1f7a285d06d5",
"target": "my-app",
"available_messages": 1000
}available_messages is the number of OTP messages left in this application's wallet at the moment
of the request. It is a live snapshot and can change as concurrent sends are accepted or refunded.
One WhatsApp-accepted OTP consumes one message; application notifications are free and do not
change this number. Do not expose this endpoint or the Authorization key in browser or mobile code:
call it only from your backend.
An application key needs no parameters. A legacy company-wide key must select an application with
?target=my-app; ?target=my-app:north is also accepted, but branches share their application's OTP
wallet. The response is sent with Cache-Control: private, no-store and counts toward the API key's
normal rate limit.