ابدأ من هنا
طبلة بتتنادى من سيرفرك بس، عمرها ما تتنادى من تطبيق أو متصفح. سيرفرك بياخد توكن ببيانات الـ client بتاعه، ويحفظ مستخدمينك وقوالبك في طبلة، ويبعت الرسايل بمفتاح القالب. كل الأمثلة بتستخدم $TABLA_API مكان عنوان الـ API.
١. حساب وحساب إرسال
اعمل حساب بإيميل الشغل بتاعك، وبعدين قدّم على حساب إرسال (tenant) من لوحة التحكم: شركتك، واستخدامك، والقنوات اللي محتاجها، وحجم الرسايل المتوقع، واسم المرسل للـ SMS. أول ما فريق طبلة يوافق، لوحة التحكم هتسيبك تعمل الـ API clients والقوالب والـ webhook ودومين الإرسال.
٢. توكن (OAuth 2.0 client credentials)
اعمل API client من لوحة التحكم بالصلاحيات (scopes) اللي محتاجها، والسر بتاعه بيظهر مرة واحدة بس. سيرفرك بيبدّل الـ client id والسر على POST /oauth/token بتوكن، ويبعته في 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"
}التوكن بيعيش ١٠ دقايق. احفظه عندك واطلب واحد جديد قبل ما يخلص بشوية، لأن طلب توكن مع كل نداء بيضيّع من حصة الطلبات بتاعتك.
خلّي السر في مخزن الأسرار بتاع سيرفرك. لما تغيّره من لوحة التحكم بتاخد سر جديد، والقديم بيفضل شغّال ٢٤ ساعة، عشان تنزّل التحديث من غير ما حاجة تقف. ولما تلغي client، التوكنات بتاعته بتقف على طول.
٣. مستخدمينك كمستلمين
المستلم هو واحد من مستخدمينك، ومتعرّف بالـ id بتاعه عندك. ابعت لغته، وموبايله المتأكد منه، وإيميله، وأجهزته كل ما يتغيّروا. طبلة بتحتفظ باللي الإرسال محتاجه بس، ومتشفّر، وبتمسحه لما تطلب (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"
}'٤. القوالب
القالب ليه مفتاح ونص لكل قناة ولكل لغة. النص ممكن يكون فيه {{variable}} بس: من غير منطق ولا كود. والأرقام بتتكتب بالأرقام العربية في النص العربي. وتقدر كمان تكتب القوالب من لوحة التحكم وتشوف شكلها قبل ما تحفظ.
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." }
}
}'كل تعديل بيعمل نسخة جديدة متنمّرة. والرسالة بتتربط بالنسخة اللي كانت شغّالة وقت ما اتقبلت، فتعديل القالب عمره ما يغيّر رسايل في الطريق.
النوع بيحدد الحارة والتفضيلات اللي تنطبق: OTP (أكواد الدخول: SMS أو واتساب أو إيميل بس)، وTRANSACTIONAL، وMARKETING. وبيتثبّت أول ما القالب يتعمل.
٥. ابعت رسالة
قول المستلم، والقالب، وبياناته. لو مبعتّش options.channels ولا options.fallback، طبلة بتختار: صندوق الرسايل لو القالب فيه نص ليه، وسلسلة بديلة واحدة على باقي قنوات القالب، الأرخص الأول.
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)
كل إرسال محتاج Idempotency-Key (من حرف لـ ١٠٠). استخدم حاجة بتعرّف الحدث عندك، زي رقم الحجز ومعاه نوع الإشعار. لو بعت نفس المفتاح بنفس الجسم تاني، هترجعلك نفس الرسالة الأولانية (Idempotent-Replayed: true)، فإعادة المحاولة بعد timeout عمرها ما تبعت مرتين. ونفس المفتاح بجسم مختلف بيرجّع 409 IDEMPOTENCY_KEY_REUSED. والدفعة (POST /v1/messages/batch، لحد ٥٠٠، يا كلها يا مفيش) بتدّي العنصر رقم n المفتاح key#n.
أكواد الدخول
قالب من نوع OTP ممكن يروح لموبايل أو إيميل من غير ما يكون ليه مستلم متسجّل. إنت اللي بتعمل الكود وبتتأكد منه، وطبلة بتوصّله في حارته لوحده، من غير ما تستنى ساعات الهدوء، وبحد أقصى ٥ مرات في الساعة لنفس العنوان، وللدول اللي حسابك مسموحله بيها بس.
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" }
}'٦. بيانات الإشعار
options.pushData بيوصّل مفاتيحك لتطبيقك جوه بيانات الإشعار، جنب messageId وtemplate وlink بتوع طبلة: زي الشاشة اللي تتفتح مثلًا. ارفع ملف الـ service account بتاع Firebase لكل تطبيق من لوحة التحكم الأول.
نصوص بس ومن غير تداخل: بحد أقصى ٢٠ مفتاح على شكل [a-zA-Z][a-zA-Z0-9_]{0,39}، وكل قيمة لحد ٢٥٦ حرف، و٢ كيلوبايت في المجموع. المفاتيح المحجوزة (messageId، template، link، from، notification، وأي حاجة بتبدأ بـ tabla أو google أو gcm، …) بترجع MESSAGE_PUSH_DATA_RESERVED. بيانات الإشعار بتعدّي على جوجل وأبل: ابعت أرقام ومفاتيح توجيه، عمرك ما تبعت بيانات شخصية.
٧. إيه اللي حصل
GET /v1/messages/{id} (صلاحية messages:read) بيقولك حالة الرسالة ومحاولات كل قناة: ACCEPTED، QUEUED، SENT، DELIVERED (لما مزوّد الخدمة يبلّغ)، FAILED أو EXPIRED. وسجل الإرسال في لوحة التحكم بيوريك نفس الكلام، من غير بيانات الرسالة.
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" }
]
}٨. webhooks الحالة
حط عنوان الـ webhook بتاعك من لوحة التحكم (https وعنوان عام بس) عشان يوصلك كل تغيير في حالة كل رسالة، بدل ما تفضل تسأل. الأحداث:
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" }]
}
}كل طلب متوقّع في هيدر Tabla-Signature: t هو وقت الإرسال بتوقيت يونكس، وv1 هو HMAC-SHA256 بالـ hex لـ t.body (الوقت ونقطة والجسم زي ما هو) بسر الـ webhook بتاعك. اتأكد منه على الجسم زي ما وصل قبل ما تحلّله، وارفض أي وقت فرقه أكتر من ٥ دقايق. وTabla-Event-Id بيفضل هو هو في كل إعادة محاولة: استخدمه عشان تتجاهل حدث اتعامل معاه قبل كده.
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;
}
رد بأي 2xx في خلال ١٠ ثواني. أي حاجة غير كده بتتعاد بعد 1m, 5m, 30m, 2h, 6h, 15h، وبعدين بتتساب.
ممكن أنواع أحداث جديدة تظهر: اتجاهل النوع اللي متعرفوش، ورد بـ 2xx برضه.
الأخطاء
كل خطأ بيرجع RFC 9457 problem details وفيه code ثابت؛ اعتمد على الكود، عمرك ما تعتمد على النص. وdetail بيكون باللغة اللي في Accept-Language (ar أو 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."
}| الكود | الحالة | معناه |
|---|---|---|
IDEMPOTENCY_KEY_REQUIRED | 400 | الإرسال مفيهوش هيدر Idempotency-Key. |
IDEMPOTENCY_KEY_REUSED | 409 | المفتاح ده اتستخدم قبل كده بجسم تاني. |
MESSAGE_TEMPLATE_UNKNOWN | 400 | مفيش قالب بالمفتاح ده. |
MESSAGE_DATA_MISSING | 400 | البيانات ناقصها متغيرات القالب محتاجها. |
MESSAGE_RECIPIENT_UNKNOWN | 400 | مفيش مستلم بالـ id ده: اعمله الأول. |
PHONE_COUNTRY_NOT_ALLOWED | 400 | حسابك مش مسموحله يبعت SMS للدولة دي. |
MESSAGE_PUSH_DATA_RESERVED | 400 | بيانات الإشعار فيها مفتاح محجوز. |
QUOTA_EXCEEDED | 429 | رسايل حسابك النهارده خلصت. |
OTP_RATE_LIMITED | 429 | أكواد كتير لنفس العنوان الساعة دي. |
RATE_LIMITED | 429 | طلبات كتير الدقيقة دي؛ استنى اللي في Retry-After. |
الحصص وحدود الطلبات
كل حساب إرسال ليه عدد طلبات في الدقيقة (لكل الـ clients بتوعه مع بعض)، وعدد رسايل في يوم القاهرة، وسرعة للإرسال الجماعي، وحد للأكواد لكل عنوان. لوحة التحكم بتوريك أرقامك. ولو عدّيت واحد منهم، الرد بيكون 429 ومعاه 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." }