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.
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 ….
curl -s "$TABLA_API/oauth/token" \
-u "$TABLA_CLIENT_ID:$TABLA_CLIENT_SECRET" \
-d grant_type=client_credentials \
-d "scope=messages:send messages:read"{
"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).
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.
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.
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" }
}
}'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.
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.
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.sentmessage.deliveredmessage.failedmessage.expired
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.
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"));
}
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)
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);
}
}
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).
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."
}| Code | Status | What it means |
|---|---|---|
IDEMPOTENCY_KEY_REQUIRED | 400 | The send has no Idempotency-Key header. |
IDEMPOTENCY_KEY_REUSED | 409 | That key was already used with another body. |
MESSAGE_TEMPLATE_UNKNOWN | 400 | No template has that key. |
MESSAGE_DATA_MISSING | 400 | The data lacks variables the template needs. |
MESSAGE_RECIPIENT_UNKNOWN | 400 | No recipient has that id: create it first. |
PHONE_COUNTRY_NOT_ALLOWED | 400 | Your tenant may not send SMS to that country. |
MESSAGE_PUSH_DATA_RESERVED | 400 | Push data used a reserved key. |
QUOTA_EXCEEDED | 429 | Your tenant's messages for today are spent. |
OTP_RATE_LIMITED | 429 | Too many codes to one address this hour. |
RATE_LIMITED | 429 | Too 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.
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." }