Bir eklenti, başka bir eklentinin yeteneğini onun kimliğini bilmeden kullanır: tüketici "mesaj gönder" / "fatura kes" ister, platform tenant'ın bağladığı sağlayıcıya yönlendirir. Bu sayfa ortak modeli (tüketici + sağlayıcı olma rehberi) anlatır; her yeteneğin kendi sözleşmesi için alt sayfalara bak.
İki rol var: tüketici (yeteneği çağırır — capability:<id>:consume) ve sağlayıcı (yeteneği sağlar — capability:<id>:provide). Eklentiler birbirini asla doğrudan çağırmaz; tek aracı platform runtime'ıdır. Tenant her yetenek için bir sağlayıcı bağlar (tek aday varsa otomatik; panelden değiştirilebilir) — tüketici hangi sağlayıcının bağlı olduğunu bilmez.
Tüketici eklenti Restomenum (platform) Sağlayıcı eklenti
(rezervasyon, POS…) (WhatsApp / e-fatura…)
│ │ │
│ POST /plugin-api/ │ │
│ capabilities/{cap}/invoke │ │
│ Bearer <apiKey> │ │
│─────────────────────────────►│ scope + ham-PII reddi │
│ │ + kota + idempotency ledger │
│ │ tenant'ın BAĞLADIĞI sağlayıcı │
│ │ (binding / tek-adayda auto-bind)│
│ │ │
│ │ imzalı POST type:"capability" │
│ │───────────────────────────────►│ imza doğrula (webhook ile aynı)
│ │ │ requestId DEDUPE (zorunlu)
│ │ { status, providerMessageId } │ opak referansı çöz + işi yap
│ │◄───────────────────────────────│ (customers/users:read + consent)
│ { requestId, status, │ │
│ providerMessageId } │ │
│◄─────────────────────────────│ │
│ │ (async) POST .../status │ nihai sonuç (DLR / GİB)
│ event: <cap>.status ◄───────│◄───────────────────────────────│consumes: [{ capability }], sağlayıcı provides: [{ capability }] beyan eder; türetilmiş capability:<id>:* scope'u otomatik eklenir (elle scope seçilmez). Portal manifest editöründe "Eklentiler-Arası Yetenekler" kartından işaretle. Sağlayıcı PII sınıfıysa (messaging) kurulumda tenant açık consent verir.Platform-tanımlı yetenekler (her biri kendi payload/durum sözleşmesine sahip):
| Yetenek | İş | PII | Senkron durum | Asenkron durum event'i |
|---|---|---|---|---|
| messaging.send | Müşteriye mesaj (WhatsApp/SMS) | Sağlayıcı PII | accepted · sent · failed | messaging.message.status: sent·delivered·read·failed |
| notify.staff | Personele bildirim | — | accepted · sent · failed | yok (fire-and-forget) |
| invoice.issue | E-fatura/e-arşiv kes | — | accepted · issued · failed | invoice.status: issued·paid·void·failed |
| fiscal.de | Fişi TSE ile imzala (KassenSichV) | — | accepted · signed · ausfall · failed | yok (SENKRON; /status → 404) |
fiscal.de ≠ invoice.issue — karıştırmayın (⏳ yakında). invoice.issue e-fatura/e-arşiv belgesi keser (GİB, asenkron invoice.status event'i); fiscal.de fişi TSE ile imzalar (KassenSichV, senkron — yanıtta biter, durum event'i yok, /status ucu 404). Ayrıca fiscal.de sağlayıcısı fişe mali bloğu receiptExtras ile yazar — tse.* namespace'inin sahibi yalnız capability:fiscal.de:provide'dır (invoice.issue sağlayıcısı dahil kimse yazamaz).Jenerik uç: POST /plugin-api/capabilities/{capabilityId}/invoke. SDK ile client.capabilities.invoke('<id>', payload) — payload tipi id'den türer, idempotencyKey her istekte zorunlu.
POST {RESTOMENUM_BASE}/plugin-api/capabilities/{capabilityId}/invoke
Authorization: Bearer <apiKey>
Content-Type: application/json
{ "payload": {
"to": { "customerId": "c_…" }, // capability-özel; ham PII (telefon/VKN) YASAK
"idempotencyKey": "iş-anlamlı-key", // ZORUNLU (≤64) — çift işlem koruması
"…": "…" // messaging: text/template · invoice: amount/items · notify: title/body
} }{ "success": true, "data": {
"requestId": "req_…", // idempotencyKey'den deterministik
"status": "accepted", // capability-özel senkron durum (aşağıdaki tablo)
"providerMessageId": "…", // sağlayıcının kimliği (varsa)
"idempotentReplay": false // true → önceki başarılı isteğin tekrarı (sağlayıcı YENİDEN çağrılmadı)
} }plugin.<pfx>.noProvider (424) = tenant henüz sağlayıcı bağlamamış. Bu bir hata değil, önkoşul eksikliğidir — özelliği gizle, retry etme. accepted teslim DEĞİLDİR; nihai sonuç asenkron <cap>.status event'iyle gelir.Sağlayıcı eklenti, provides ettiği yetenek için platformdan imzalı capability isteği alır (actionUrl'üne, yoksa webhookUrl'üne). İmza şeması webhook ile birebir aynıdır.
POST <sağlayıcının actionUrl'ü (yoksa webhookUrl)>
X-Restomenum-Signature: t=<unixSec>,v1=HMAC_SHA256(webhookSecret,"<t>.<rawBody>")
X-Restomenum-Event: capability
X-Restomenum-Capability: invoice.issue
X-Restomenum-Request: req_…
{ "type": "capability", "capability": "invoice.issue", "v": 1,
"environment": "sandbox", // "sandbox" | "production" — imzalı gövdede
"tenantId": "…", "requestId": "req_…",
"consumer": { "pluginId": "…" }, // isteği yapan tüketici (kota/metering)
"occurredAt": 0,
"payload": { … } } // platform allowlist'inden geçmiş (ham PII yok)Adımlar (SDK ile):
import { verifyAndParseCapability, capabilityResponse } from '@restomenum/plugin-sdk';
import type { InvoiceIssuePayload, InvoiceIssueStatus } from '@restomenum/plugin-sdk';
// 1) İMZA + ŞEKİL doğrula (webhook ile AYNI HMAC şeması). null → 401 dön.
const req = await verifyAndParseCapability<InvoiceIssuePayload>(rawBody, sigHeader, {
getSecret: (tenantId) => installStore.find(tenantId)?.webhookSecret,
});
if (!req) return res.status(401).json({ error: 'invalid_signature' });
// 2) requestId DEDUPE (ZORUNLU sözleşme — review kriteri): aynı requestId tekrar gelirse işi YENİDEN
// YAPMA (mesaj/fatura), önceki providerMessageId ile AYNI yanıtı dön.
const prior = await store.find(req.requestId);
if (prior) return res.json(capabilityResponse(prior.status, { providerMessageId: prior.providerMessageId }));
// 3) İşi yap: to.customerId OPAK → gerekirse client.customers.get(customerId) (customers:read + PII consent).
const providerMessageId = await issueInvoiceAtGib(req.payload);
await store.save(req.requestId, { status: 'issued', providerMessageId });
// 4) Senkron yanıt — status capability'nin responseStatuses whitelist'inden olmalı (invoice: accepted|issued|failed).
res.json(capabilityResponse<InvoiceIssueStatus>('issued', { providerMessageId }));requestId tekrar gelirse işi (mesaj/fatura) YENİDEN YAPMA — önceki providerMessageId ile aynı yanıtı dön. Timeout sonrası platform aynı istekle retry edebilir; dedupe yoksa çift SMS / çift fatura olur.Nihai sonucu (DLR / GİB durumu) dakikalar sonra asenkron raporla:
// invoice.issue / messaging.send ASYNC sonucu (dakikalar sonra): nihai durumu raporla →
// platform YALNIZ istek sahibi tüketiciye hedefli <cap>.status event'i teslim eder (broadcast yok).
await client.capabilities.reportStatus('invoice.issue', {
requestId: req.requestId, // capability isteğinde aldığın req_… (kendi fatura no'n DEĞİL)
status: 'paid', // issued→paid ileri; void/failed terminal (MONOTON)
providerMessageId,
});
// notify.staff fire-and-forget'tir (asenkron durum YOK).status'u yeteneğin responseStatuses whitelist'inden olmalı (messaging/notify → accepted|sent|failed, invoice → accepted|issued|failed). Serbest metin alanları (text, title, fatura description) PII taşıyabilir → yalnız amaçla işle, loglama/sızdırma yapma.Tam kod plugin.<errorPrefix>.<suffix>; <pfx> = capability id'nin İLK segmenti: messaging.send → plugin.messaging, notify.staff → plugin.notify, invoice.issue → plugin.invoice (ör. notify.staff için plugin.notify.noProvider, plugin.notify.staff.* DEĞİL). SDK'da CAPABILITY_ERRORS + capabilityErrorCode('<id>', suffix) tip-güvenli guard sağlar.
| Kod | HTTP | Anlam |
|---|---|---|
| plugin.scope.denied | 403 | Tüketicide capability:<id>:consume scope'u yok. |
| plugin.<pfx>.noProvider | 424 | Tenant sağlayıcı bağlamamış (ya da ≥2 aday, seçim yok). Önkoşul eksik → özelliği gizle, RETRY ETME. |
| plugin.<pfx>.providerUnavailable | 503 | Sağlayıcı inaktif/askıda/breaker-open/ulaşılamıyor. Aynı key ile sonra dene. |
| plugin.<pfx>.timeout | 504 | Sağlayıcı ≈10s içinde yanıtlamadı. AYNI idempotencyKey ile retry (belirsiz sonuç). |
| plugin.<pfx>.duplicateInProgress | 409 | Aynı idempotencyKey eşzamanlı işleniyor. |
| plugin.<pfx>.idempotencyKeyReused | 409 | Aynı key FARKLI içerikle kullanıldı (Stripe keys_reused paritesi). |
| plugin.<pfx>.providerChanged | 409 | Belirsiz sonuçtan (timeout) sonra tenant sağlayıcıyı DEĞİŞTİRDİ → çift-işlem (çift fatura/SMS) koruması, bu key ile retry engellendi. Manuel uzlaştır. |
| plugin.<pfx>.selfTarget | 409 | Tüketici == bağlı sağlayıcı (döngü tabanı). |
| plugin.<pfx>.suspended | 403 | Tüketici eklenti kill-switch ile askıya alınmış. |
| plugin.<pfx>.consumerBlocked | 403 | Tenant bu tüketiciyi panelden engellemiş. |
| plugin.<pfx>.rawPiiForbidden | 400 | to içinde customerId/userId dışında alan (ham PII). |
| plugin.<pfx>.idempotencyKeyRequired | 400 | idempotencyKey eksik/geçersiz (zorunlu, ≤64). |
| plugin.<pfx>.invalidPayload | 400 | Payload doğrulaması başarısız (nested metadata, geçersiz tutar/kanal/hedef). |
| plugin.capability.notFound | 404 | Bilinmeyen capability (jenerik /capabilities/{cap}/* ucunda). |
Kota: capability çağrıları ortak cap:<id> kovasını kullanır (varsayılan 60/dk/install; dev-store projesinde 5/dk) — bkz. Limitler.
capability:* scope'ları + PII.