WhatsApp in your product, minus the Meta plumbing.
Tabli is the secure abstraction layer: your servers talk to a small REST API with scoped keys; we hold the Meta credentials, run the queue, retry, log and sign webhooks back to you.
- Base URL tabli.in/api/v1/whatsapp
- JSON in, JSON out
- Idempotency keys on sends
- Request log in the developer console
curl -X POST https://tabli.in/api/v1/whatsapp/messages/template \
-H "Authorization: Bearer tb_live_••••••••" \
-H "Content-Type: application/json" \
-d '{
"phone": "919876543210",
"template": "appointment_confirm",
"language": "en",
"variables": ["Kavya", "Dr. Iyer", "Tue 9 Sep, 4:30 pm"],
"header_media_url": "https://cdn.example.in/clinic-map.jpg",
"contact_name": "Kavya Rao",
"idempotency_key": "appt-88213"
}'Scoped API keys per business
Create keys under Developer → API keys in the app. Each key belongs to exactly one business and carries the scopes you pick. The key is shown once — store it in your secrets manager.
- Header:
Authorization: Bearer tb_live_…(orX-API-Key) - Scopes:
messages:sendcontacts:readcontacts:writetemplates:readconversations:read - Optional expiry; revoke instantly from the console
- Every call logged with status, duration and body for debugging
Keys never expose Meta access tokens. Phone numbers are digits with country code (919876543210); we normalise common formats.
# Every request: scoped key in the Authorization header
curl https://tabli.in/api/v1/whatsapp/templates \
-H "Authorization: Bearer tb_live_••••••••••••••••••••••••"
# Alternatively
curl https://tabli.in/api/v1/whatsapp/templates -H "X-API-Key: tb_live_••••••••"Six endpoints cover most integrations
Base URL https://tabli.in/api/v1/whatsapp. All responses are JSON with ok: true on success.
| Method | Path | Scope | Description |
|---|---|---|---|
| POST | /api/v1/whatsapp/messages/template | messages:send | Send an approved template to a phone number (opens or re-opens a conversation). |
| POST | /api/v1/whatsapp/messages/text | messages:send | Send free-form text inside an open 24-hour customer service window. |
| GET | /api/v1/whatsapp/contacts | contacts:read | List contacts (newest first); filter by ?phone=…; ?limit up to 200. |
| POST | /api/v1/whatsapp/contacts | contacts:write | Create or update a contact by phone — name, email, attributes, tags, opt-in. |
| GET | /api/v1/whatsapp/templates | templates:read | List templates with status, category, variable count and buttons. |
| GET | /api/v1/whatsapp/conversations | conversations:read | List recent conversations; ?phone=… returns the last 50 messages for that contact. |
Send a template
Variables can be positional (array) or named by position ({ "1": "…" }). Sends are queued and delivered by our worker; track status via webhooks.
curl -X POST https://tabli.in/api/v1/whatsapp/messages/template \
-H "Authorization: Bearer tb_live_••••••••" \
-H "Content-Type: application/json" \
-d '{
"phone": "919876543210",
"template": "appointment_confirm",
"language": "en",
"variables": ["Kavya", "Dr. Iyer", "Tue 9 Sep, 4:30 pm"],
"header_media_url": "https://cdn.example.in/clinic-map.jpg",
"contact_name": "Kavya Rao",
"idempotency_key": "appt-88213"
}'HTTP/1.1 202 Accepted
{
"ok": true,
"message": {
"id": "msg_01j8xk5v…",
"wa_message_id": null,
"conversation_id": "conv_01j8xk…",
"contact_id": "ct_01j8x…",
"direction": "outbound",
"type": "template",
"template": "appointment_confirm",
"status": "queued",
"timestamp": "2026-09-07T10:04:12.000Z"
}
}Send text (inside the 24-hour window)
Returns 422 WINDOW_CLOSED if the customer has not messaged in the last 24 hours.
curl -X POST https://tabli.in/api/v1/whatsapp/messages/text \
-H "Authorization: Bearer tb_live_••••••••" \
-H "Content-Type: application/json" \
-d '{ "phone": "919876543210", "text": "Your report is ready. Reply 1 to book a follow-up.", "preview_url": false }'Upsert a contact
Attributes and tags become available in segments, broadcasts and flows immediately.
curl -X POST https://tabli.in/api/v1/whatsapp/contacts \
-H "Authorization: Bearer tb_live_••••••••" \
-H "Content-Type: application/json" \
-d '{
"phone": "919876543210",
"name": "Aarav Mehta",
"email": "aarav@example.in",
"attributes": { "course": "NEET", "batch": "2025", "fee_due": "yes" },
"tags": ["Batch 2025", "Lead"],
"opt_in": true
}'
# → 201 Created (or 200 if updated)
{ "ok": true, "created": true, "contact": { "id": "ct_…", "phone": "919876543210", "name": "Aarav Mehta", "tags": ["Batch 2025", "Lead"], "opt_in_status": "opted_in", … } }List conversations
curl "https://tabli.in/api/v1/whatsapp/conversations?phone=919876543210" \
-H "Authorization: Bearer tb_live_••••••••"
{
"ok": true,
"conversations": [
{ "id": "conv_…", "status": "open",
"contact": { "id": "ct_…", "phone": "919876543210", "name": "Aarav Mehta" },
"last_message_at": "2026-09-07T10:04:12.000Z",
"messages": [ { "id": "msg_…", "direction": "inbound", "type": "text", "body": "Class 12. Fees?", "status": "read", … } ] }
]
}Signed events, retried until you say 2xx
Add an HTTPS endpoint under Developer → Webhooks, choose events and copy the signing secret. Deliveries are POSTed as JSON, signed with HMAC-SHA256 and retried with backoff up to 6 times. Every delivery is inspectable in the console.
| Event | When |
|---|---|
| message.received | A customer sent a message to your number. |
| message.sent | An outbound message was accepted by WhatsApp. |
| message.delivered | WhatsApp delivered the message to the device. |
| message.read | The customer opened the message. |
| message.failed | WhatsApp rejected the message (includes Meta error code). |
| contact.created | A new contact was created (inbox, import or API). |
| contact.updated | Name, tags, attributes or opt-in status changed. |
| conversation.created | A new conversation thread was opened. |
| conversation.updated | Assignment or status (open / pending / closed) changed. |
| template.updated | Meta approved, rejected or paused a template. |
| campaign.completed | A broadcast finished sending (with totals). |
- X-Tabli-Event: event name
- X-Tabli-Timestamp: unix seconds
- X-Tabli-Signature: v1=HMAC_SHA256(secret, timestamp + "." + rawBody) as hex
POST https://your-app.example.in/webhooks/tabli
Content-Type: application/json
User-Agent: Tabli-Webhooks/1.0
X-Tabli-Event: message.received
X-Tabli-Timestamp: 1788343452
X-Tabli-Signature: v1=3f1c9a…e07b
{
"event": "message.received",
"business_id": "biz_01j8w…",
"created_at": "2026-09-07T10:04:12.000Z",
"data": {
"message": { "id": "msg_…", "direction": "inbound", "type": "text", "body": "Class 12. Fees?", "timestamp": "…" },
"contact": { "id": "ct_…", "phone": "919876543210", "name": "Aarav Mehta" },
"conversation": { "id": "conv_…", "status": "open" }
}
}import crypto from "node:crypto";
// Use the RAW request body (not re-serialised JSON).
export function verifyTabliSignature(rawBody: string, headers: Record<string, string | undefined>, secret: string) {
const ts = headers["x-tabli-timestamp"];
const sig = headers["x-tabli-signature"]; // "v1=<hex>"
if (!ts || !sig) return false;
// Reject deliveries older than 5 minutes (replay protection)
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = "v1=" + crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const a = Buffer.from(expected); const b = Buffer.from(sig);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express example
app.post("/webhooks/tabli", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyTabliSignature(req.body.toString("utf8"), req.headers as Record<string, string>, process.env.TABLI_WEBHOOK_SECRET!)) return res.status(401).end();
const evt = JSON.parse(req.body.toString("utf8"));
// handle evt.event / evt.data — respond 2xx quickly; do slow work async
res.status(200).end();
});Error codes you can branch on
Errors return { "error": "human message", "code": "CODE" }. Codes are stable; messages may change.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | VALIDATION | Body failed validation — the error message names the field. |
| 401 | — | Missing, invalid, revoked or expired API key. |
| 402 | INSUFFICIENT_BALANCE | Prepaid wallet cannot cover this message. Top up and retry (idempotency key safe). |
| 403 | — | API key lacks the required scope (e.g. messages:send). |
| 403 | BUSINESS_MISMATCH | business_id in the body does not match the key’s business. |
| 404 | TEMPLATE_NOT_FOUND | No approved template with that name and language for this business. |
| 409 | NOT_CONNECTED | WhatsApp is not connected for this business (or was disconnected). |
| 422 | DEMO_ACCOUNT | The business is on a demo account; connect a real number to send. |
| 422 | ACCOUNT_ERROR | The WhatsApp connection is in an error state; reconnect from the app. |
| 422 | NO_PHONE | No WhatsApp phone number is attached to the connected account. |
| 422 | INVALID_PHONE | Phone must be digits with country code (e.g. 919876543210). |
| 422 | BLOCKED | The contact is blocked in your workspace. |
| 422 | OPTED_OUT | Contact has opted out; template sends are refused (inbox replies still allowed). |
| 422 | TEMPLATE_NOT_APPROVED | Template exists but is pending, rejected or paused. |
| 422 | MISSING_VARIABLES | Fewer variables supplied than the template requires. |
| 422 | WINDOW_CLOSED | 24-hour window closed — send an approved template instead of text. |
| 500 | — | Internal error; safe to retry with the same idempotency key. |
Meta-side failures after queuing (for example an unregistered number) arrive as a message.failed webhook with error_code and a readable error_message; the wallet is refunded automatically.
Need a Postman collection or help with a specific stack (PHP, Python, Google Apps Script)? Contact us.
Generate your first API key in two minutes.
Create a workspace, connect WhatsApp, open Developer → API keys. The request log shows every call while you integrate.
No credit card required · Free plan for evaluation · Cancel anytime