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.
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 } │ │
│◄────────────────────────────│ │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).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).resolveRecipientPhone) — { customerId } → customers.get, { packetId } → packets.get(...).customer. Telefon saklanmaz (rehber yok); tenant'ın vermediği veri sizin üzerinizden sızamaz.POST /plugin-api/capabilities/{cap}/invoke.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
}
}// 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ı" } } }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.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.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.
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": { … } }
}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
}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 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.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Ğ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Ü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
} }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).| Kod | HTTP | Anlamı / ne yapmalı |
|---|---|---|
| plugin.messaging.noProvider | 424 | Tenant 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.providerUnavailable | 503 | Sağlayıcı inaktif / askıda / circuit-breaker açık / ulaşılamıyor. Aynı key ile sonra tekrar dene. |
| plugin.messaging.timeout | 504 | Sağlayıcı 10 sn içinde yanıtlamadı. AYNI idempotencyKey ile retry et. |
| plugin.messaging.duplicateInProgress | 409 | Aynı idempotencyKey şu anda işleniyor (eşzamanlı çift çağrı). |
| plugin.messaging.idempotencyKeyReused | 409 | Aynı idempotencyKey FARKLI içerikle kullanıldı (Stripe keys_reused paritesi). Her benzersiz mesaj için benzersiz key ver. |
| plugin.messaging.providerChanged | 409 | Belirsiz 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.rawPiiForbidden | 400 | to içinde customerId dışında alan (telefon/e-posta) var — ham PII gönderilemez. |
| plugin.messaging.invalidPayload | 400 | metadata veya template.params yalnız string/sayı/boolean değer alır — nested obje/dizi reddedilir. |
| plugin.messaging.suspended | 403 | Eklentiniz kill-switch ile askıya alınmış — mesaj gönderemez. |
| plugin.messaging.idempotencyKeyRequired | 400 | idempotencyKey eksik/geçersiz (zorunlu, ≤64). |
| plugin.messaging.invalidChannel | 400 | channel whatsapp|sms dışında. |
| plugin.messaging.textTooLong | 400 | text 1600 karakteri aşıyor. |
| plugin.messaging.payloadTooLarge | 400 | template.params veya metadata 1KB sınırını aşıyor. |
| plugin.messaging.selfTarget | 409 | Tüketici, tenant'ın bağlı sağlayıcısının kendisi (kendine yönlendirme reddedilir). |
| plugin.messaging.consumerBlocked | 403 | Tenant, eklentinizin mesaj göndermesini panelden engellemiş. Kalıcı durum — özelliği gizle, retry etme. |
| plugin.scope.denied | 403 | Gerekli scope onaylı değil (send → messaging:send, status → messaging:provide). |
| plugin.messaging.notFound | 404 | Status raporu: requestId bulunamadı ya da bu isteği siz işlemediniz (yalnız kendi işlediğin istekleri raporlayabilirsin). |
| plugin.messaging.invalidStatus | 400 | Status 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).
401. Kendi kripto kodunu yazma — SDK verifyAndParseCapability kullan.tenantId'yi kayıtlı kurulumlarınla eşleştir; tanımadığın tenant → 401.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.text serbest metindir ama gereksiz kişisel veri koyma; şablon (template) kullanımı tercih et.accepted dön.