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 202 straight 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."
  }'
FieldDescription
toPhone number with country code, like +919811122233. Spaces and dashes are fine. Opens the chat if you haven’t talked before. Use this or conversationId.
conversationIdAn existing chat, DM or group. This is how you post into a WhatsApp group: find its id with GET /conversations or from any webhook.
textThe message, up to 4,096 characters. With media, it becomes the caption.
mediaOptional 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.
fromOptional. The number to send from, by id or phone. Default: the number that last talked to this person, else your first connected number.
replyToOptional. 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.

PacingEach 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 chats50 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 chatsOnce 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 numbersA 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.
Requests120 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 2xx within 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 id to ignore repeats, and the message’s status rather than arrival order.
  • Answer 410 Gone and 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 ping event.

Events

EventWhen
message.receivedSomeone messaged one of your numbers, in a DM or a group.
message.sentA message left your number: from the API, the Tenashi inbox, an automation, a broadcast, or typed on the phone itself.
message.deliveredWhatsApp delivered your message to their phone.
message.readThey opened it (only when they have read receipts on).
message.failedThe message couldn’t be sent. error says why.
number.connectedA WhatsApp number linked or came back online.
number.disconnectedA 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
  }
}
StatusCodeMeaning
400bad_request, invalid_phoneSomething in the request is missing or malformed.
401unauthorizedMissing, wrong or revoked key.
401key_owner_removedThe key’s creator left the workspace.
402plan_requiredThe workspace has no active plan.
403forbiddenThe key’s creator isn’t allowed to do this.
404not_found, number_not_foundNo such chat, message, webhook or number here.
409number_disconnected, no_connected_numberReconnect the number in Tenashi.
409idempotency_in_progressThe first request with this key is still running.
413 / 422media_too_large, unsupported_media, media_unreachableThe file is too big, of a type WhatsApp won’t take, or we couldn’t download it.
422send_blockedPaused by ban protection. Don’t retry the same text; personalise it.
429rate_limited, new_chat_limitSlow down; see Retry-After.
500 / 503internal, unavailableOur side. Retry with the same Idempotency-Key.

Building something and stuck? Email support with the request you sent and the response you got.