Eklentiler-Arası Mesajlaşma (messaging.send) ✓ Canlı

Eklentiniz, tenant'ın bağladığı mesaj sağlayıcısı eklenti (WhatsApp, SMS…) üzerinden müşterilere mesaj gönderebilir — sağlayıcının kim olduğunu bilmeden. Platform standart messaging.send sözleşmesini tanımlar, isteği imzalayıp bağlı sağlayıcıya yönlendirir; eklentiler birbirini asla doğrudan çağırmaz.

1 · Nasıl çalışır — capability modeli

Sektör standardı platform-tanımlı sağlayıcı arayüzü desenidir (Shopify carrier service, Android implicit intent, Home Assistant notify): tüketici yeteneği ister ("mesaj gönder"), sağlayıcıyı tenant seçer. Böylece rezervasyon eklentiniz WhatsApp sağlayıcısıyla da SMS sağlayıcısıyla da kod değişmeden çalışır; tenant sağlayıcıyı değiştirse bile siz etkilenmezsiniz.

Tüketici eklenti                Restomenum                    Sağlayıcı eklenti
(rezervasyon, sadakat…)         (platform)                    (WhatsApp / SMS)
      │                             │                              │
      │ POST /plugin-api/           │                              │
      │   messaging/send            │                              │
      │ Bearer <apiKey>             │                              │
      │────────────────────────────►│ scope + ham-PII reddi        │
      │                             │ + kota + idempotency ledger  │
      │                             │ bağlı sağlayıcıyı çöz        │
      │                             │ (tenant binding / auto-bind) │
      │                             │                              │
      │                             │ imzalı POST type:"capability"│
      │                             │─────────────────────────────►│ imza doğrula
      │                             │                              │ requestId DEDUPE
      │                             │  { status:"accepted",        │ opak ref → telefon çöz
      │                             │    providerMessageId }       │ (customerId/packetId + consent)
      │                             │◄─────────────────────────────│ upstream'e kuyrukla
      │  { requestId, status,       │                              │
      │    providerMessageId }      │                              │
      │◄────────────────────────────│                              │
  • Binding: tenant panelde tek bir sağlayıcı bağlar; tek aktif sağlayıcı kuruluysa otomatik bağlanır. Broadcast yok → aynı müşteriye çift mesaj yapısal olarak imkansız.
  • Beyan & scope: manifest'te consumes:[{capability:"messaging.send"}] (tüketici) veya provides:[{capability:"messaging.send"}] (sağlayıcı) beyan et; türetilmiş capability:messaging.send:consume / capability:messaging.send:provide scope'u OTOMATİK eklenir (provide PII sınıfı — kurulumda tenant consent'i). Legacy messaging:send/messaging:provide scope'ları geçiş penceresinde hâlâ kabul edilir (dual-accept).
  • Hedef (tipli birlik): to tam olarak BİR opak referans taşır — { customerId } (Cari/CRM müşteri) veya { packetId } (paket/teslimat müşterisi — CRM'de olmayan walk-in dahil). Ham telefon/e-posta TAŞINAMAZ (rawPiiForbidden 400).
  • PII kuralı: telefonu sağlayıcı gönderim anında çözer (SDK resolveRecipientPhone) — { customerId } → customers.get, { packetId } → packets.get(...).customer. Telefon saklanmaz (rehber yok); tenant'ın vermediği veri sizin üzerinizden sızamaz.
  • Ortak model + sağlayıcı rehberi: Yetenekler — Genel Bakış. Aynı altyapıyı notify.staff ve invoice.issue de kullanır — jenerik uç: POST /plugin-api/capabilities/{cap}/invoke.

2 · Tüketici — mesaj gönder

İstek
POST {RESTOMENUM_BASE}/plugin-api/messaging/send
Authorization: Bearer <apiKey>
Content-Type: application/json

{
  "payload": {
    "to": { "customerId": "c_9f2…" },        // TİPLİ BİRLİK: { customerId } | { packetId } (tam biri).
                                              // ham telefon/e-posta 400 (rawPiiForbidden); telefonu SAĞLAYICI çözer.
    "channel": "whatsapp",                    // ops.: "whatsapp" | "sms" (yoksa sağlayıcı seçer)
    "text": "Rezervasyonunuz onaylandı 🎉",   // text VEYA template'ten en az biri (text ≤1600)
    "template": { "id": "rez-onay", "params": { "ad": "Ali" } },   // ops. (params ≤1KB)
    "idempotencyKey": "rez-42-onay",          // ZORUNLU ≤64 — çift mesaj koruması (aynı key farklı içerik → 409)
    "metadata": { "kaynak": "rezervasyon" }   // ops. ≤1KB, YALNIZ string/sayı/boolean değer (nested yasak); PII koyma
  }
}
Yanıt
// başarı — sağlayıcı isteği işledi (accepted = kuyruğa alındı; TESLİM DEĞİL)
{ "success": true, "data": {
    "requestId": "req_ab12…",            // idempotencyKey'den deterministik — durum eşleştirmede kullan
    "status": "accepted",                // "accepted" | "sent" | "failed"
    "providerMessageId": "wm_123",       // sağlayıcının kendi mesaj kimliği (varsa)
    "idempotentReplay": true             // yalnız tekrar çağrıda: önceki sonuç döndü, sağlayıcı ÇAĞRILMADI
} }

// sağlayıcının İŞ reddi (HTTP 200 — istek platformda başarıyla işlendi)
{ "success": true, "data": { "requestId": "req_…", "status": "failed",
    "error": { "code": "invalid_template", "message": "rez-onay bulunamadı" } } }
SDK ile (önerilen)
import { RestomenumClient } from '@restomenum/plugin-sdk';   // v1.5.0+

const r = await client.messaging.send({
  to: { customerId },
  text: 'Rezervasyonunuz onaylandı',
  idempotencyKey: `rez-${rezId}-onay`,   // iş-anlamlı + deterministik seç
});
// r.status: 'accepted' | 'sent' | 'failed'   (accepted ≠ delivered!)

// Sağlayıcı bağlı değilse: ApiError('plugin.messaging.noProvider', 424) → özelliği zarifçe gizle.
// Timeout/providerUnavailable: AYNI idempotencyKey ile retry et — başarı asla çift gönderilmez.
template bugün soyutlamanın DIŞINDADIR — bilinen sınırlama. template.id sağlayıcı-tanımlıdır: geçerli bir kimliği ancak tenant'ın hangi sağlayıcıyı bağladığını ve o sağlayıcının şablon sözlüğünü bilirsen verebilirsin. Bu, bu sayfanın en başındaki "sağlayıcının kim olduğunu bilmeden" vaadiyle çelişir. Sonuçları:
  • Taşınabilir tek yol bugün text'tir. template kullanırsan tenant sağlayıcı değiştirdiğinde invalid_template ile kırılırsın.
  • Ama text her zaman yeterli değildir: WhatsApp'ta işletme-başlatımlı mesajlar 24 saatlik hizmet penceresi dışında genellikle onaylı şablon gerektirir; serbest metin sağlayıcı tarafından reddedilebilir (sağlayıcının kendi hata kodu — platform kodu değildir, aşağıdaki tabloda yer almaz).
  • Dil alanı yoktur: sözleşmede template.language bulunmaz. Şablonlar kanal tarafında dil bazında onaylandığından sağlayıcı dili kendi seçmek zorundadır — çok dilli tenant'ta yanlış dil gönderim hatasına yol açabilir.
Yani işletme-başlatımlı bildirim senaryosunda capability şu an uçtan uca taşınabilir değildir. Çözüm aşağıda: platform şablon sözlüğü.
accepted ≠ teslim edildi. status:"accepted" yalnız sağlayıcının isteği kuyruğuna aldığı anlamına gelir; alıcıya ulaştığı anlamına gelmez. Teslim durumunu öğrenmek için messaging.message.status event'ine abone ol (aşağıda §4) — sağlayıcı raporladıkça yalnız sana teslim edilir.
Çift mesaj = gerçek maliyet. idempotencyKey bu yüzden zorunlu: aynı key aynı requestId'yi üretir; başarıyla sonuçlanmış bir istek tekrar gönderilirse sağlayıcıçağrılmaz, önceki sonuç döner (idempotentReplay:true). Timeout aldığında yeni key ÜRETME — aynı key ile retry et.

2b · Şablon sözlüğü (platform-sahipli)

✓ Canlı Kanal tarafı (WhatsApp) işletme-başlatımlı mesajda önceden onaylı şablon ister; ama tüketici sağlayıcıyı bilmez. Bu ikisini uzlaştırmanın sektör standardı, sözlüğü platformun sahiplenmesidir (Twilio Content API, Android Intent action/extras, Shopify carrier service): tüketici semantik bir id verir, sağlayıcı onu kendi kanal-onaylı şablonuna eşler. Tenant sağlayıcı değiştirince tüketici kodu değişmez.

idAnlamparams alanları
order.confirmedSipariş onaylandıcustomerName, orderNo
order.readySipariş hazırcustomerName
order.on_waySipariş yoldacustomerName, etaMinutes
order.delayedSipariş gecikticustomerName, etaMinutes
order.cancelledSipariş iptal edildicustomerName, orderNo
reservation.confirmedRezervasyon onaylandıcustomerName, dateTime, partySize
reservation.reminderRezervasyon hatırlatmacustomerName, dateTime
Tüketici — semantik id + dil
await client.messaging.send({
  to: { customerId },
  template: {
    id: "order.on_way",              // platform sözlüğünden (taşınabilir)
    language: "tr",                  // ops. — verilmezse sağlayıcı tenant dilini kullanır
    params: { customerName: "Ada", etaMinutes: 15 },
  },
  idempotencyKey: `order-${orderId}-on_way`,
});
Sağlayıcı — karşıladığın şablonları BEYAN et (manifest)
provides: [
  { capability: "messaging.send", v: 1,
    templates: ["order.confirmed", "order.ready", "order.on_way"] }
]
// MCP ile: set_capabilities(provides:[{ capability:"messaging.send", templates:[…] }])
// Sözlük dışı id reddedilir (kaydetmede issue) — sessizce düşmez.
Sözlük henüz canlı değildir. Bugün derleme-zamanı güvenliği (SDK MessageTemplateId) ve sağlayıcı beyanı sağlar; relay tarafındaki doğrulama (desteklenmeyen id sağlayıcıya hiç gitmez → templateUnsupported) Restomenum runtime'ına geçtiğinde canlı garanti gelir. O ana kadar sağlayıcının gerçekten karşıladığı id'lerle sınırlı kal.
Kaçış kapısı korunur: template.id sözlük dışı bir değer de alabilir (sağlayıcıya özgü) — geriye dönük uyum bozulmaz, ama o çağrı taşınabilir değildir: tenant sağlayıcı değiştirdiğinde invalid_template alırsın.
Her capability sözlük gerektirmez. Ölçüt: tüketici, sağlayıcı tarafında önceden kayıtlı bir nesneyi adıyla çağırmak zorunda mı? messaging.send → evet (kanal şablonu onaylatır). notify.staff ve invoice.issue → hayır (serbest içerik / veriyi tüketici tam verir), bu yüzden onlarda sözlük yoktur.

3 · Sağlayıcı — istekleri işle

Bir mesajlaşma sağlayıcısı yazıyorsanız (WhatsApp/SMS entegrasyonu): manifest'te messaging:provide scope'unu isteyin. Platform, tenant'ın bağlı sağlayıcısı olarak actionUrl'ünüze (yoksa webhookUrl) imzalı type:"capability" POST atar — imza şeması webhook ile birebir aynıdır.

Aldığınız istek
POST {actionUrl ?? webhookUrl}
Content-Type: application/json
X-Restomenum-Signature: t=<unixSec>,v1=<HMAC_SHA256(webhookSecret,"<t>.<rawBody>")>
X-Restomenum-Event: capability
X-Restomenum-Capability: messaging.send
X-Restomenum-Request: req_ab12…

{
  "type": "capability",                       // action/hook/webhook'tan bu alanla ayır
  "capability": "messaging.send",
  "v": 1,
  "environment": "sandbox",                   // "sandbox" | "production" — imzalı gövdede
  "tenantId": "kcK88…",
  "requestId": "req_ab12…",                   // AYNI requestId tekrar gelebilir → DEDUPE ZORUNLU
  "consumer": { "pluginId": "rezervasyon-x" },// isteği yapan tüketici (kota/metering için görünür)
  "occurredAt": 1780633662954,
  "payload": { "to": { "customerId": "c_9f2…" }, "channel": "whatsapp",
               "text": "…",
               // template tüketiciden AYNEN gelir — language dahil (sağlayıcı: id'yi kendi
               // kanal-onaylı şablonuna eşle, language verilmişse O DİLİN onaylı sürümünü seç)
               "template": { "id": "order.on_way", "language": "tr",
                             "params": { "customerName": "Ada", "etaMinutes": 15 } },
               "metadata": { … } }
}
Dönmeniz gereken yanıt
HTTP 200 (≤10 sn içinde)
{
  "status": "accepted",                 // "accepted" (kuyruğa alındı) | "sent" | "failed"
  "providerMessageId": "wm_123",        // ops. — kendi mesaj kimliğin
  "error": { "code": "…", "message": "…" }   // yalnız status:"failed" ise
}
SDK ile (önerilen)
import { verifyAndParseCapability, capabilityResponse } from '@restomenum/plugin-sdk';

// actionUrl handler'ında (webhook ile AYNI imza şeması):
const req = await verifyAndParseCapability(rawBody, headers['x-restomenum-signature'], {
  getSecret: (tenantId) => installStore.find(tenantId)?.webhookSecret,
});
if (!req) return res.status(401).json({ error: 'invalid_signature' });

// ZORUNLU DEDUPE: aynı requestId → mesajı YENİDEN GÖNDERME, önceki yanıtı dön.
const seen = sentStore.find(req.requestId);
if (seen) return res.json(capabilityResponse(seen.status, { providerMessageId: seen.providerMessageId }));

// telefonu TEK RESOLVER ile çöz (kaynağa göre dispatch: customerId→customers.get, packetId→packets.get):
import { resolveRecipientPhone } from '@restomenum/plugin-sdk';
const phone = await resolveRecipientPhone(client, req.payload.to);  // { customerId } | { packetId }
if (!phone) return res.json(capabilityResponse('failed', { error: { code: 'no_phone' } }));  // PII yetkisi/consent yok
// Scope: { customerId } → customers:read | { packetId } → orders:read + customers:read (+ tenant PII consent).
// … phone ile WhatsApp/SMS upstream'ine kuyrukla …
sentStore.save(req.requestId, { status: 'accepted', providerMessageId });
return res.json(capabilityResponse('accepted', { providerMessageId }));
requestId dedupe ZORUNLU (review kriteri). Platform retry'ında veya tüketici retry'ında aynı requestId tekrar gelebilir. Aynı requestId için mesajı yeniden gönderme; önceki providerMessageId ile aynı yanıtı dön. Kalıcı bir depoda (Redis/DB, TTL'li) requestId → providerMessageId tut.
messaging:provide PII scope'udur: serbest mesaj metni + müşteri referansı alırsınız; telefonu çözmek için ayrıca customers:read + tenant PII consent'i gerekir. Aldığınız veriyi yalnız mesaj göndermek için işleyin; customer.redact geldiğinde o müşteriye ait kayıtları silin.

4 · Teslim durumu (DLR) — messaging.message.status

Sağlayıcı, upstream'den (Meta/Twilio) teslim raporu aldıkça platforma bildirir; platform bu bilgiyi yalnız isteği yapan tüketici kuruluma hedefli messaging.message.status event'i olarak teslim eder — başka hiçbir eklenti bu event'i alamaz (broadcast yok).

Sağlayıcı: durum raporla (SDK: client.messaging.reportStatus)
// SAĞLAYICI (messaging:provide) — upstream DLR'ı raporla:
POST {RESTOMENUM_BASE}/plugin-api/messaging/status
Authorization: Bearer <apiKey>

{ "requestId": "req_ab12…",              // capability isteğinde aldığın kimlik
  "status": "delivered",                  // "sent" | "delivered" | "read" | "failed"
  "providerMessageId": "wm_123",          // ops.
  "error": { "code": "…", "message": "…" } }   // yalnız failed ile

// Yanıt: { "success": true, "data": { "recorded": true, "dispatched": true, "eventId": "evt_msgst_…" } }
// dispatched:false = rapor kayıtlı ama tüketiciye event gitmedi (kaldırılmış/abone değil/askıda) — hata değil.
// Yalnız KENDİ işlediğin istekleri raporlayabilirsin (başkasınınki → 404). Tekrar rapor güvenli
// (platform en-az-bir-kez teslim eder; tüketici zarf.id ile dedupe eder).
Tüketici: event payload'ı
// TÜKETİCİ — manifest: events: ["messaging.message.status"] (+ events:subscribe scope)
// Webhook'una gelen zarf (yalnız SANA — istek sahibi kuruluma):
{ "id": "evt_msgst_5f1e9b2c…",            // deterministik: aynı raporun tekrarı aynı id (dedupe kolay)
  "type": "messaging.message.status",
  "version": "1", "environment": "sandbox", "tenantId": "…", "occurredAt": 1718200031000,
  "data": {
    "requestId": "req_ab12…",             // client.messaging.send dönüşündeki requestId ile eşle
    "status": "delivered",                 // sent → carrier'a verildi · delivered → ulaştı · read → okundu · failed → teslim edilemedi
    "providerMessageId": "wm_123",
    "error": { "code": "…" }               // yalnız failed ile
  } }
Sırasız + en-az-bir-kez. Durumlar sıra garantisi olmadan gelebilir (upstream DLR doğası — geç bir sent, delivered'dan sonra) ve platform aynı event'i birden çok kez teslim edebilir. İki koruma uygula: (1) zarfid'si ile dedupe (aynı id = aynı rapor); (2) requestId üzerinden forward-only upsert — kaydını sıralamada geri götürme (sent<delivered<read; failed terminal). failed aldığında alternatif kanal/yeniden deneme kararı senin iş mantığında; error.message sağlayıcı-kontrollü serbest metindir, UI'da text olarak bas (HTML/XSS yok).

5 · Hata kodları

KodHTTPAnlamı / ne yapmalı
plugin.messaging.noProvider424Tenant mesaj sağlayıcısı bağlamamış (veya birden fazla aday var, seçim yapılmamış) — önkoşul eksik. Özelliği zarifçe gizle; retry etme.
plugin.messaging.providerUnavailable503Sağlayıcı inaktif / askıda / circuit-breaker açık / ulaşılamıyor. Aynı key ile sonra tekrar dene.
plugin.messaging.timeout504Sağlayıcı 10 sn içinde yanıtlamadı. AYNI idempotencyKey ile retry et.
plugin.messaging.duplicateInProgress409Aynı idempotencyKey şu anda işleniyor (eşzamanlı çift çağrı).
plugin.messaging.idempotencyKeyReused409Aynı idempotencyKey FARKLI içerikle kullanıldı (Stripe keys_reused paritesi). Her benzersiz mesaj için benzersiz key ver.
plugin.messaging.providerChanged409Belirsiz sonuçtan (timeout/unreachable) sonra tenant mesaj sağlayıcısını DEĞİŞTİRDİ — orijinal sağlayıcı mesajı göndermiş olabilir; çift mesaja karşı bu key ile retry engellendi. Yeni bir key ile yalnız gönderilmediğinden eminsen tekrar dene.
plugin.messaging.rawPiiForbidden400to içinde customerId dışında alan (telefon/e-posta) var — ham PII gönderilemez.
plugin.messaging.invalidPayload400metadata veya template.params yalnız string/sayı/boolean değer alır — nested obje/dizi reddedilir.
plugin.messaging.suspended403Eklentiniz kill-switch ile askıya alınmış — mesaj gönderemez.
plugin.messaging.idempotencyKeyRequired400idempotencyKey eksik/geçersiz (zorunlu, ≤64).
plugin.messaging.invalidChannel400channel whatsapp|sms dışında.
plugin.messaging.textTooLong400text 1600 karakteri aşıyor.
plugin.messaging.payloadTooLarge400template.params veya metadata 1KB sınırını aşıyor.
plugin.messaging.selfTarget409Tüketici, tenant'ın bağlı sağlayıcısının kendisi (kendine yönlendirme reddedilir).
plugin.messaging.consumerBlocked403Tenant, eklentinizin mesaj göndermesini panelden engellemiş. Kalıcı durum — özelliği gizle, retry etme.
plugin.scope.denied403Gerekli scope onaylı değil (send → messaging:send, status → messaging:provide).
plugin.messaging.notFound404Status raporu: requestId bulunamadı ya da bu isteği siz işlemediniz (yalnız kendi işlediğin istekleri raporlayabilirsin).
plugin.messaging.invalidStatus400Status raporu: status sent|delivered|read|failed dışında.

Ortak zarf/hata kuralları için Veri API'si Genel Bakış, limitler için Limitler & Kotalar (messaging: 60 istek/dk/kurulum — ayrı kova).

6 · Güvenlik & en iyi pratikler

  • İmzayı her zaman doğrula (sağlayıcı tarafı): ham gövde + HMAC + ±5 dk tolerans; geçersizse 401. Kendi kripto kodunu yazma — SDK verifyAndParseCapability kullan.
  • Cross-tenant kontrolü: tenantId'yi kayıtlı kurulumlarınla eşleştir; tanımadığın tenant → 401.
  • Zarif bozulma (tüketici tarafı): noProvider (sağlayıcı yok) ve consumerBlocked (tenant seni engellemiş) birer hata değil durumdur — mesaj özelliğini gizle, retry etme. Tenant panelden istediği tüketiciyi engelleyebilir ve sağlayıcı sağlığını (breaker) izleyebilir.
  • Metne PII gömme: text serbest metindir ama gereksiz kişisel veri koyma; şablon (template) kullanımı tercih et.
  • Sağlayıcıysan hızlı yanıtla: 10 sn tavan — upstream'e (WhatsApp/Twilio) senkron bekleme, kuyruğa al ve accepted dön.