Tenashi API
Send WhatsApp messages from your own systems through the number you already use, and get a signed webhook the moment a message arrives, is delivered or is read.
Overview
The API is plain JSON over HTTPS. Every request goes to https://usetenashi.com/api/v1 with your API key. Messages go out from your connected WhatsApp numbers, into DMs or groups, and show up in your team inbox like any other reply, so whoever picks up the conversation sees the whole history.
Three things to know before you start:
- Sends are queued and paced. A send returns
202straight away; the message leaves your number a few seconds later, at a human pace. Webhooks tell you when it was sent, delivered and read. - It runs on your own WhatsApp number, not the WhatsApp Business Platform. There are no templates to approve and groups work, but WhatsApp still bans numbers that spam. Message people who expect to hear from you.
- Everything is per workspace. A key reads and sends only in the workspace it was created in.
Machine-readable spec: /api/v1/openapi.json (OpenAPI 3.1, imports into Postman and Insomnia).
Authentication
Create a key in Settings → API & webhooks (workspace admins only). It is shown once, so copy it then. Send it as a bearer token:
Authorization: Bearer tk_live_…- A key acts with the permissions of the teammate who created it. If they leave the workspace, the key stops working; create a new one.
- Keep keys on your server. Never put one in a web page or a mobile app.
- Revoke a key in settings and it stops working on the next request.
- The API works on every paid plan. A workspace without an active plan gets
402 plan_required.
Send a message
POST/api/v1/messages
curl https://usetenashi.com/api/v1/messages \
-H "Authorization: Bearer $TENASHI_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: booking-4821-reminder" \
-d '{
"to": "+919811122233",
"text": "Hi Priya, your class is at 5 pm today. Reply here if you need to move it."
}'| Field | Description |
|---|---|
to | Phone number with country code, like +919811122233. Spaces and dashes are fine. Opens the chat if you haven’t talked before. Use this or conversationId. |
conversationId | An existing chat, DM or group. This is how you post into a WhatsApp group: find its id with GET /conversations or from any webhook. |
text | The message, up to 4,096 characters. With media, it becomes the caption. |
media | Optional file: { "url": "https://…" } (we download it) or { "base64": "…", "mimetype": "application/pdf" }. Add filename for documents. Images up to 5 MB, video 16 MB, PDFs and Office files 25 MB. |
from | Optional. The number to send from, by id or phone. Default: the number that last talked to this person, else your first connected number. |
replyTo | Optional. Id of a message in the same chat to quote. |
Send Idempotency-Key with any unique string to make retries safe: a repeat with the same key within 24 hours returns the first response and sends nothing.
Response
202 Accepted with the queued message. Keep its id to match the webhooks that follow. A warning field appears when the send went through but came close to a ban-protection limit.
{
"message": {
"id": "cmh1q2w3e4r5t6y7u8i9o0p",
"conversationId": "cmh0a9s8d7f6g5h4j3k2l1",
"direction": "out",
"type": "text",
"text": "Hi Priya, your class is at 5 pm today. Reply here if you need to move it.",
"status": "queued",
"error": null,
"media": null,
"replyTo": null,
"sender": null,
"chat": { "type": "dm", "name": "Priya Nair", "phone": "+919811122233" },
"number": { "id": "cmf8k2j4h6g8f0d2s4a6", "phone": "+919800012345", "label": "Front desk" },
"createdAt": "2026-10-28T09:41:06.000Z"
}
}A file, or a group
{
"to": "+919811122233",
"text": "Your invoice for October",
"media": { "url": "https://example.com/invoices/INV-1042.pdf", "filename": "INV-1042.pdf" }
}{
"conversationId": "cmh0a9s8d7f6g5h4j3k2l1",
"text": "Reminder: the batch starts Monday at 7 am."
}Pacing and limits
The API sends through the same protection as your inbox, because the fastest way to lose a WhatsApp number is to send like a machine.
| Pacing | Each number sends one message every 4 to 12 seconds, slower late at night. A batch of 100 takes about 10 minutes. Messages wait in the queue; nothing is dropped. |
| New chats | 50 per number per day through the API: chats with someone who has never written to you. Replies to people who wrote first are not counted. Over it: 429 new_chat_limit. |
| Same text to many chats | Once the exact same text has gone to 5 chats within 10 minutes, more sends of it are paused: 422 send_blocked. Personalise messages with the person’s name or details and this never triggers. |
| New numbers | A newly linked number warms up: 20 messages on day one, doubling daily to 1,000. Sends over the day’s allowance wait in the queue until there’s room. |
| Requests | 120 per minute per key, across all endpoints. Over it: 429 rate_limited with a Retry-After header. |
Read messages and chats
GET/api/v1/messages/{id}
One message in the same shape as above, with its current status: queued → sent → delivered → read, or failed with an error. Inbound messages are received. Media comes with a url you download with the same bearer key.
GET/api/v1/numbers
Your WhatsApp numbers: id, label, phone, status and connected.
GET/api/v1/conversations
Your chats, newest first, 50 per page. Each has id, type (dm or group), name, phone, waAccountId, lastMessageAt and unreadCount. Pass ?filter=groups to list only groups, and ?cursor= with the previous response’s nextCursor for the next page.
Webhooks
Add an endpoint in Settings → API & webhooks, or by API. Tenashi POSTs a JSON event to it for every event you subscribe to. The URL must be https and reachable from the internet; Zapier, Make and n8n webhook URLs work as they are.
POST /your/webhook HTTP/1.1
Content-Type: application/json
X-Tenashi-Event: message.received
X-Tenashi-Delivery: evt_5f0c8e2a91b34d7c6e1f2a90
X-Tenashi-Timestamp: 1761644467
X-Tenashi-Signature: v1=6b1f0c…
{
"id": "evt_5f0c8e2a91b34d7c6e1f2a90",
"type": "message.received",
"createdAt": "2026-10-28T09:41:07.512Z",
"workspaceId": "cmg3x8v1b0000ab12cd34ef56",
"data": {
"message": {
"id": "cmh1r7t8y9u0i1o2p3a4s5d6",
"conversationId": "cmh0a9s8d7f6g5h4j3k2l1",
"direction": "in",
"type": "text",
"text": "Can I move my class to Thursday?",
"status": "received",
"error": null,
"media": null,
"replyTo": null,
"sender": { "phone": "+919811122233", "name": "Priya Nair" },
"chat": { "type": "dm", "name": "Priya Nair", "phone": "+919811122233" },
"number": { "id": "cmf8k2j4h6g8f0d2s4a6", "phone": "+919800012345", "label": "Front desk" },
"createdAt": "2026-10-28T09:41:06.000Z"
}
}
}- Answer with any
2xxwithin 10 seconds. Do slow work after you reply. - A failed delivery is retried 8 more times over about a day: after 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h and 12 h. Redirects count as failures.
- Delivery is at least once and not always in order. Use
idto ignore repeats, and the message’sstatusrather than arrival order. - Answer
410 Goneand Tenashi stops sending to that endpoint. One that has failed for 3 days straight is switched off too; turn it back on in settings once it’s fixed. - Settings shows the last 100 deliveries per endpoint, with a Resend button for events up to 7 days old, and a Send test button that posts a
pingevent.
Events
| Event | When |
|---|---|
message.received | Someone messaged one of your numbers, in a DM or a group. |
message.sent | A message left your number: from the API, the Tenashi inbox, an automation, a broadcast, or typed on the phone itself. |
message.delivered | WhatsApp delivered your message to their phone. |
message.read | They opened it (only when they have read receipts on). |
message.failed | The message couldn’t be sent. error says why. |
number.connected | A WhatsApp number linked or came back online. |
number.disconnected | A number went offline. Sends from it fail until it’s reconnected. |
Message events carry data.message in the same shape the API returns. Messages imported from your chat history never fire events. Number events carry data.number:
{
"id": "evt_0a1b2c3d4e5f60718293a4b5",
"type": "number.disconnected",
"createdAt": "2026-10-28T11:02:44.120Z",
"workspaceId": "cmg3x8v1b0000ab12cd34ef56",
"data": {
"number": {
"id": "cmf8k2j4h6g8f0d2s4a6",
"label": "Front desk",
"phone": "+919800012345",
"status": "disconnected",
"connected": false
}
}
}Verify signatures
Every delivery is signed with the endpoint’s signing secret (whsec_…, shown when you add the endpoint and under Reveal in settings). The signature is the hex HMAC-SHA256 of the timestamp, a dot, and the raw body. Check it, and refuse timestamps more than 5 minutes old so an old delivery can’t be replayed.
Node.js
import crypto from "node:crypto";
// rawBody must be the exact bytes Tenashi sent, before JSON parsing.
export function verifyTenashi(headers, rawBody, secret) {
const timestamp = headers["x-tenashi-timestamp"];
const signature = String(headers["x-tenashi-signature"] || "").replace(/^v1=/, "");
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
return (
fresh &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
}Python
import hashlib, hmac, time
def verify_tenashi(headers, raw_body: bytes, secret: str) -> bool:
timestamp = headers["X-Tenashi-Timestamp"]
signature = headers["X-Tenashi-Signature"].removeprefix("v1=")
expected = hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
fresh = abs(time.time() - int(timestamp)) < 300
return fresh and hmac.compare_digest(signature, expected)Manage webhooks by API
For tools that subscribe themselves. Creating, changing and removing endpoints needs a key made by a workspace admin.
GET/api/v1/webhooks
POST/api/v1/webhooks
PATCH/api/v1/webhooks/{id}
DELETE/api/v1/webhooks/{id}
events is a list of event names; leave it out for every event, including ones added later. PATCH takes url, events, description and enabled. A workspace can have 10 endpoints.
curl https://usetenashi.com/api/v1/webhooks \
-H "Authorization: Bearer $TENASHI_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/tenashi", "events": ["message.received"]}'
# 201
{
"webhook": { "id": "cmh2…", "url": "https://example.com/tenashi", "events": ["message.received"], "enabled": true, … },
"secret": "whsec_…"
}Errors
Errors share one shape. Branch on code; show message.
{
"error": {
"code": "send_blocked",
"message": "You've sent this exact message to 5 chats in the last 10 minutes.",
"count": 5
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request, invalid_phone | Something in the request is missing or malformed. |
| 401 | unauthorized | Missing, wrong or revoked key. |
| 401 | key_owner_removed | The key’s creator left the workspace. |
| 402 | plan_required | The workspace has no active plan. |
| 403 | forbidden | The key’s creator isn’t allowed to do this. |
| 404 | not_found, number_not_found | No such chat, message, webhook or number here. |
| 409 | number_disconnected, no_connected_number | Reconnect the number in Tenashi. |
| 409 | idempotency_in_progress | The first request with this key is still running. |
| 413 / 422 | media_too_large, unsupported_media, media_unreachable | The file is too big, of a type WhatsApp won’t take, or we couldn’t download it. |
| 422 | send_blocked | Paused by ban protection. Don’t retry the same text; personalise it. |
| 429 | rate_limited, new_chat_limit | Slow down; see Retry-After. |
| 500 / 503 | internal, unavailable | Our side. Retry with the same Idempotency-Key. |
Building something and stuck? Email support with the request you sent and the response you got.