Developers

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
Quick start · send a template
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"
  }'
Authentication

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_… (or X-API-Key)
  • Scopes: messages:send contacts:read contacts:write templates:read conversations: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.

Authentication
# 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_••••••••"
Endpoints

Six endpoints cover most integrations

Base URL https://tabli.in/api/v1/whatsapp. All responses are JSON with ok: true on success.

MethodPathScopeDescription
POST/api/v1/whatsapp/messages/templatemessages:sendSend an approved template to a phone number (opens or re-opens a conversation).
POST/api/v1/whatsapp/messages/textmessages:sendSend free-form text inside an open 24-hour customer service window.
GET/api/v1/whatsapp/contactscontacts:readList contacts (newest first); filter by ?phone=…; ?limit up to 200.
POST/api/v1/whatsapp/contactscontacts:writeCreate or update a contact by phone — name, email, attributes, tags, opt-in.
GET/api/v1/whatsapp/templatestemplates:readList templates with status, category, variable count and buttons.
GET/api/v1/whatsapp/conversationsconversations:readList 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.

Request
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"
  }'
Response
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.

POST /messages/text
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.

POST /contacts
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

GET /conversations?phone=…
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", … } ] }
  ]
}
Webhooks

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.

EventWhen
message.receivedA customer sent a message to your number.
message.sentAn outbound message was accepted by WhatsApp.
message.deliveredWhatsApp delivered the message to the device.
message.readThe customer opened the message.
message.failedWhatsApp rejected the message (includes Meta error code).
contact.createdA new contact was created (inbox, import or API).
contact.updatedName, tags, attributes or opt-in status changed.
conversation.createdA new conversation thread was opened.
conversation.updatedAssignment or status (open / pending / closed) changed.
template.updatedMeta approved, rejected or paused a template.
campaign.completedA broadcast finished sending (with totals).
Headers on every delivery
  • X-Tabli-Event: event name
  • X-Tabli-Timestamp: unix seconds
  • X-Tabli-Signature: v1=HMAC_SHA256(secret, timestamp + "." + rawBody) as hex
Delivery
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" }
  }
}
verify.ts · Node.js
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();
});
Errors

Error codes you can branch on

Errors return { "error": "human message", "code": "CODE" }. Codes are stable; messages may change.

HTTPCodeMeaning
400VALIDATIONBody failed validation — the error message names the field.
401Missing, invalid, revoked or expired API key.
402INSUFFICIENT_BALANCEPrepaid wallet cannot cover this message. Top up and retry (idempotency key safe).
403API key lacks the required scope (e.g. messages:send).
403BUSINESS_MISMATCHbusiness_id in the body does not match the key’s business.
404TEMPLATE_NOT_FOUNDNo approved template with that name and language for this business.
409NOT_CONNECTEDWhatsApp is not connected for this business (or was disconnected).
422DEMO_ACCOUNTThe business is on a demo account; connect a real number to send.
422ACCOUNT_ERRORThe WhatsApp connection is in an error state; reconnect from the app.
422NO_PHONENo WhatsApp phone number is attached to the connected account.
422INVALID_PHONEPhone must be digits with country code (e.g. 919876543210).
422BLOCKEDThe contact is blocked in your workspace.
422OPTED_OUTContact has opted out; template sends are refused (inbox replies still allowed).
422TEMPLATE_NOT_APPROVEDTemplate exists but is pending, rejected or paused.
422MISSING_VARIABLESFewer variables supplied than the template requires.
422WINDOW_CLOSED24-hour window closed — send an approved template instead of text.
500Internal 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