اتنقل للمحتوى
طبلةالمطوّرين

ابدأ من هنا

طبلة بتتنادى من سيرفرك بس، عمرها ما تتنادى من تطبيق أو متصفح. سيرفرك بياخد توكن ببيانات الـ client بتاعه، ويحفظ مستخدمينك وقوالبك في طبلة، ويبعت الرسايل بمفتاح القالب. كل الأمثلة بتستخدم $TABLA_API مكان عنوان الـ API.

١. حساب وحساب إرسال

اعمل حساب بإيميل الشغل بتاعك، وبعدين قدّم على حساب إرسال (tenant) من لوحة التحكم: شركتك، واستخدامك، والقنوات اللي محتاجها، وحجم الرسايل المتوقع، واسم المرسل للـ SMS. أول ما فريق طبلة يوافق، لوحة التحكم هتسيبك تعمل الـ API clients والقوالب والـ webhook ودومين الإرسال.

اعمل حساب

٢. توكن (OAuth 2.0 client credentials)

اعمل API client من لوحة التحكم بالصلاحيات (scopes) اللي محتاجها، والسر بتاعه بيظهر مرة واحدة بس. سيرفرك بيبدّل الـ client id والسر على POST /oauth/token بتوكن، ويبعته في Authorization: Bearer ….

الطلب (client_secret_basic، و client_secret_post شغّال برضه)
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، طبلة بتختار: صندوق الرسايل لو القالب فيه نص ليه، وسلسلة بديلة واحدة على باقي قنوات القالب، الأرخص الأول.

ابعت رسالة واحدة (صلاحية 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" }
    }
  }'
الرد: اتقبلت، ولسه ماتبعتتش
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. وسجل الإرسال في لوحة التحكم بيوريك نفس الكلام، من غير بيانات الرسالة.

إشعار مانفعش فراح 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" }
  ]
}

٨. webhooks الحالة

حط عنوان الـ webhook بتاعك من لوحة التحكم (https وعنوان عام بس) عشان يوصلك كل تغيير في حالة كل رسالة، بدل ما تفضل تسأل. الأحداث:

  • message.sent
  • message.delivered
  • message.failed
  • message.expired
اللي بيوصل للـ endpoint بتاعك
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 بيفضل هو هو في كل إعادة محاولة: استخدمه عشان تتجاهل حدث اتعامل معاه قبل كده.

التأكد من التوقيع بـ 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"));
}
التأكد من التوقيع بـ 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)
التأكد من التوقيع بـ 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);
    }
}
التأكد من التوقيع بـ 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;
}

رد بأي 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_REQUIRED400الإرسال مفيهوش هيدر Idempotency-Key.
IDEMPOTENCY_KEY_REUSED409المفتاح ده اتستخدم قبل كده بجسم تاني.
MESSAGE_TEMPLATE_UNKNOWN400مفيش قالب بالمفتاح ده.
MESSAGE_DATA_MISSING400البيانات ناقصها متغيرات القالب محتاجها.
MESSAGE_RECIPIENT_UNKNOWN400مفيش مستلم بالـ id ده: اعمله الأول.
PHONE_COUNTRY_NOT_ALLOWED400حسابك مش مسموحله يبعت SMS للدولة دي.
MESSAGE_PUSH_DATA_RESERVED400بيانات الإشعار فيها مفتاح محجوز.
QUOTA_EXCEEDED429رسايل حسابك النهارده خلصت.
OTP_RATE_LIMITED429أكواد كتير لنفس العنوان الساعة دي.
RATE_LIMITED429طلبات كتير الدقيقة دي؛ استنى اللي في 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." }