Eklentiler-Arası Mesajlaşma (messaging.send) ⏳ Yakında

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.
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.

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": { … }, "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.