Skip to content
TablaDevelopers

Getting started

Tabla is called from your backend only, never from an app or a browser. Your server gets a token with its client credentials, keeps your users and templates in Tabla, and sends messages by template key. Every example uses $TABLA_API for the API's base URL.

1. An account and a tenant

Create an account with your work e-mail, then apply for a tenant from the console: your company, your use case, your channels, your expected volume and your SMS sender name. Once Tabla's team approves it, the console lets you create API clients, templates, your webhook and your sending domain.

Create an account

2. A token (OAuth 2.0 client credentials)

Create an API client in the console with the scopes it needs; its secret is shown once. Your server exchanges the client id and secret at POST /oauth/token for a token, and sends it as Authorization: Bearer ….

Request (client_secret_basic; client_secret_post works too)
curl -s "$TABLA_API/oauth/token" \
  -u "$TABLA_CLIENT_ID:$TABLA_CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d "scope=messages:send messages:read"
Response
{
  "access_token": "eyJhbGciOiJIUzI1NiJ9…",
  "token_type": "Bearer",
  "expires_in": 600,
  "scope": "messages:read messages:send"
}

A token lasts 10 minutes. Cache it and ask for a new one shortly before it ends; asking for every call wastes your request budget.

Keep the secret in your server's secret store. Rotating it in the console gives a new secret and keeps the old one working for 24 hours, so you can deploy without downtime. Revoking a client stops its tokens at once.

3. Your users as recipients

A recipient is one of your users, keyed by your own user id. Send their language, verified phone, e-mail and push devices whenever they change; Tabla keeps only what delivery needs, encrypted, and erases it when you ask (POST /v1/recipients/{id}/erasure).

Create or update a recipient
curl -s -X PUT "$TABLA_API/v1/recipients/user-42" \
  -H "Authorization: Bearer $TABLA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "locale": "ar",
    "phone": "+201001234567",
    "phoneVerified": true,
    "email": "mona@example.com"
  }'

4. Templates

A template has a key and text per channel and per language. Text may use {{variable}} placeholders only: no logic, no code. Numbers render in Arabic-Indic digits in Arabic text. You can also write templates in the console, with a preview.

Create a template
curl -s -X PUT "$TABLA_API/v1/templates/booking.confirmed" \
  -H "Authorization: Bearer $TABLA_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "category": "TRANSACTIONAL",
    "push": {
      "ar": { "title": "اتأكد حجزك", "body": "مستنيينك الساعة {{hour}} يا {{name}}." },
      "en": { "title": "Booking confirmed", "body": "See you at {{hour}}, {{name}}." }
    },
    "sms": {
      "ar": { "text": "اتأكد حجزك الساعة {{hour}}." },
      "en": { "text": "Your booking at {{hour}} is confirmed." }
    }
  }'

Each change makes a new, numbered version. A message is pinned to the version current when it was accepted, so editing a template never changes messages already on their way.

The category decides the lane and which preferences apply: OTP (sign-in codes: SMS, WhatsApp or e-mail only), TRANSACTIONAL and MARKETING. It is fixed once the template exists.

5. Send a message

Name the recipient, the template and its data. Leave out options.channels and options.fallback and Tabla picks: the inbox when the template has inbox text, plus one fallback chain over the template's other channels, cheapest first.

Send one message (scope messages:send)
curl -s "$TABLA_API/v1/messages" \
  -H "Authorization: Bearer $TABLA_TOKEN" \
  -H "Idempotency-Key: booking-8f2c41-confirmed" \
  -H "Content-Type: application/json" \
  -d '{
    "recipient": { "id": "user-42" },
    "template": "booking.confirmed",
    "data": { "name": "Mona", "hour": 18 },
    "options": {
      "fallback": ["PUSH", "SMS"],
      "pushData": { "type": "booking", "bookingId": "8f2c41" }
    }
  }'
Response: accepted, not yet sent
HTTP/1.1 202 Accepted
Idempotent-Replayed: false

{ "id": "6b0e2c1a-3d4f-4e5a-9b8c-7d6e5f4a3b2c", "status": "ACCEPTED" }

Idempotency

Every send needs an Idempotency-Key (1 to 100 characters). Use something that identifies the event in your system, such as the booking id plus the notification type. Sending the same key with the same body again answers the original message (Idempotent-Replayed: true), so retrying after a timeout never sends twice; the same key with another body is 409 IDEMPOTENCY_KEY_REUSED. A batch (POST /v1/messages/batch, up to 500, all or nothing) gives item n the key key#n.

Sign-in codes

An OTP template can go to a phone or e-mail with no recipient record. You generate and check the code; Tabla delivers it on its own lane, ignoring quiet hours, at most 5 times an hour to one address and only to the countries your tenant allows.

Send a code to a phone
curl -s "$TABLA_API/v1/messages" \
  -H "Authorization: Bearer $TABLA_TOKEN" \
  -H "Idempotency-Key: otp-$(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "recipient": { "phone": "+201001234567", "locale": "ar" },
    "template": "auth.otp",
    "data": { "code": "482913" }
  }'

6. Push data

options.pushData carries your own keys to your app in the push's data payload, beside Tabla's messageId, template and link: for example the screen to open. Upload each app's Firebase service account in the console first.

Flat strings only: at most 20 keys matching [a-zA-Z][a-zA-Z0-9_]{0,39}, values up to 256 characters, 2 KB in all. Reserved keys (messageId, template, link, from, notification, anything starting tabla, google or gcm, …) are refused with MESSAGE_PUSH_DATA_RESERVED. Push data passes through Google and Apple: ids and routing keys, never personal data.

7. What happened

GET /v1/messages/{id} (scope messages:read) answers the message's status and every channel's attempts: ACCEPTED, QUEUED, SENT, DELIVERED (when the provider reports it), FAILED or EXPIRED. The console's delivery log shows the same, without the message's data.

A push that fell back to SMS
GET /v1/messages/6b0e2c1a-…
{
  "id": "6b0e2c1a-3d4f-4e5a-9b8c-7d6e5f4a3b2c",
  "status": "SENT",
  "template": "booking.confirmed",
  "lane": "TRANSACTIONAL",
  "channels": [
    { "leg": 0, "channel": "PUSH", "status": "FAILED" },
    { "leg": 0, "channel": "SMS", "status": "SENT" }
  ]
}

8. Status webhooks

Set your webhook URL in the console (public https only) to hear about every message as its status changes, instead of polling. The events:

  • message.sent
  • message.delivered
  • message.failed
  • message.expired
What your endpoint receives
POST /tabla/webhooks HTTP/1.1
Content-Type: application/json
Tabla-Signature: t=1791651600,v1=5f0c…e2
Tabla-Event-Id: 0b8e6a1c-2f4d-4e7b-9c1a-6d3f8e2b5a70
User-Agent: Tabla-Webhooks/1

{
  "id": "0b8e6a1c-2f4d-4e7b-9c1a-6d3f8e2b5a70",
  "type": "message.failed",
  "createdAt": "2026-10-09T12:40:00Z",
  "data": {
    "messageId": "6b0e2c1a-3d4f-4e5a-9b8c-7d6e5f4a3b2c",
    "status": "FAILED",
    "template": "booking.confirmed",
    "recipientId": "user-42",
    "legs": [{ "leg": 0, "channel": "SMS", "status": "FAILED" }]
  }
}

Every request is signed in the Tabla-Signature header: t is the Unix time it was sent, and v1 the hex HMAC-SHA256 of t.body (the time, a dot and the raw body) with your webhook secret. Check it against the raw body before you parse it, and refuse a timestamp more than 5 minutes off. Tabla-Event-Id stays the same across retries: use it to ignore one you've already handled.

Verifying a signature in Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

/**
 * rawBody: the request body exactly as received (a Buffer or string), before any JSON parsing.
 * header: the Tabla-Signature header. secret: your whsec_… signing secret.
 */
export function verifyTablaSignature(rawBody, header, secret, nowMs = Date.now()) {
  const parts = Object.fromEntries(
    String(header ?? "").split(",").map((part) => part.trim().split("=")),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
  if (Math.abs(nowMs / 1000 - t) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}
Verifying a signature in Python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_tabla_signature(raw_body: bytes, header: str, secret: str, now: float | None = None) -> bool:
    """raw_body: the request body exactly as received, before any JSON parsing."""
    try:
        parts = dict(part.strip().split("=", 1) for part in header.split(","))
        t = int(parts["t"])
        given = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs((time.time() if now is None else now) - t) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, given)
Verifying a signature in Java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

final class TablaWebhooks {

    private static final long TOLERANCE_SECONDS = 5 * 60;

    /** rawBody: the request body exactly as received, before any JSON parsing. */
    static boolean verify(byte[] rawBody, String header, String secret, Instant now) throws Exception {
        String t = null;
        String v1 = null;
        for (String part : header.split(",")) {
            String[] kv = part.trim().split("=", 2);
            if (kv.length == 2 && kv[0].equals("t")) t = kv[1];
            if (kv.length == 2 && kv[0].equals("v1")) v1 = kv[1];
        }
        if (t == null || v1 == null) return false;
        long timestamp;
        byte[] given;
        try {
            timestamp = Long.parseLong(t);
            given = HexFormat.of().parseHex(v1);
        } catch (IllegalArgumentException e) {
            return false;
        }
        if (Math.abs(now.getEpochSecond() - timestamp) > TOLERANCE_SECONDS) return false;
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
        return MessageDigest.isEqual(mac.doFinal(rawBody), given);
    }
}
Verifying a signature in Dart
import 'dart:convert';

import 'package:crypto/crypto.dart';

const toleranceSeconds = 5 * 60;

/// [rawBody]: the request body exactly as received, before any JSON parsing.
bool verifyTablaSignature(List<int> rawBody, String header, String secret, {DateTime? now}) {
  final parts = <String, String>{};
  for (final part in header.split(',')) {
    final i = part.indexOf('=');
    if (i > 0) parts[part.substring(0, i).trim()] = part.substring(i + 1).trim();
  }
  final t = int.tryParse(parts['t'] ?? '');
  final given = parts['v1'];
  if (t == null || given == null) return false;
  final nowSeconds = (now ?? DateTime.now()).millisecondsSinceEpoch ~/ 1000;
  if ((nowSeconds - t).abs() > toleranceSeconds) return false;
  final expected = Hmac(sha256, utf8.encode(secret)).convert([...utf8.encode('$t.'), ...rawBody]).toString();
  return _constantTimeEquals(expected, given);
}

bool _constantTimeEquals(String a, String b) {
  if (a.length != b.length) return false;
  var diff = 0;
  for (var i = 0; i < a.length; i++) {
    diff |= a.codeUnitAt(i) ^ b.codeUnitAt(i);
  }
  return diff == 0;
}

Answer any 2xx within 10 seconds. Anything else is retried after 1m, 5m, 30m, 2h, 6h, 15h, then given up.

New event types may appear: ignore a type you don't know, and answer 2xx anyway.

Errors

Every error is RFC 9457 problem details with a stable code; branch on the code, never on the text. detail is in the language of Accept-Language (ar or en).

An error
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "urn:tabla:problem:MESSAGE_DATA_MISSING",
  "title": "Bad Request",
  "status": 400,
  "code": "MESSAGE_DATA_MISSING",
  "detail": "The message lacks variables the template needs."
}
The errors a sender meets most
CodeStatusWhat it means
IDEMPOTENCY_KEY_REQUIRED400The send has no Idempotency-Key header.
IDEMPOTENCY_KEY_REUSED409That key was already used with another body.
MESSAGE_TEMPLATE_UNKNOWN400No template has that key.
MESSAGE_DATA_MISSING400The data lacks variables the template needs.
MESSAGE_RECIPIENT_UNKNOWN400No recipient has that id: create it first.
PHONE_COUNTRY_NOT_ALLOWED400Your tenant may not send SMS to that country.
MESSAGE_PUSH_DATA_RESERVED400Push data used a reserved key.
QUOTA_EXCEEDED429Your tenant's messages for today are spent.
OTP_RATE_LIMITED429Too many codes to one address this hour.
RATE_LIMITED429Too many requests this minute; wait for Retry-After.

Quotas and rate limits

Each tenant has a request budget per minute (all its clients together), a number of messages per Cairo day, a bulk sending rate and a limit on codes per address. The console shows yours. Past one, the answer is 429 with Retry-After.

Over the budget
HTTP/1.1 429 Too Many Requests
Retry-After: 17
Content-Type: application/problem+json

{ "status": 429, "code": "RATE_LIMITED", "detail": "Too many requests this minute." }