Skip to main content

Channel Router Service — API Contracts

Version: 1.0 Status: Draft Owner: Messaging Core Last Updated: 2026-04-21 Companion: SERVICE_OVERVIEW · APPLICATION_LOGIC · SYNC_CONTRACT · SECURITY_MODEL

The channel-router exposes three interface planes:

  1. gRPC data plane on :50071 (mTLS) — RouteWithFallback, DeliverNow, GetRecipientProfile, GetConversationSession. Consumed by sms-orchestrator and peer data-plane services.
  2. gRPC control plane on :50072 (mTLS) — Policy + adapter admin RPCs. Consumed by admin-dashboard and developer-portal-service.
  3. HTTPS REST on :3071 (Kong-fronted; JWT + RBAC) — Admin portal + tenant portal + OTT webhook ingress.

1. gRPC service — ChannelRouterService.v1

Package ghasi.sms.channel.v1. Full proto in SYNC_CONTRACT §3.

syntax = "proto3";
package ghasi.sms.channel.v1;
option go_package = "github.com/ghasi/sms-gateway/channel/v1";

service ChannelRouterService {
// Hot path. P95 ≤ 50 ms. mTLS required. Returns after step 0 is dispatched;
// the ladder continues async and terminates with a single outcome event.
rpc RouteWithFallback (RouteWithFallbackRequest) returns (RouteWithFallbackAck);

// Single-channel immediate dispatch (no fallback). Used for
// OTT adapter tests, admin resends, and platform emergency broadcasts.
rpc DeliverNow (DeliverNowRequest) returns (DeliverNowAck);

// Read the conversation session state for an (senderId, msisdn, tenantId) tuple.
// Returns NotFound if no OPEN session.
rpc GetConversationSession (GetConversationSessionRequest)
returns (ConversationSession);

// Read the learned capability profile for a recipient.
// MSISDN is hashed server-side; raw never surfaces in responses.
rpc GetRecipientProfile (GetRecipientProfileRequest) returns (RecipientProfile);
}

message RouteWithFallbackRequest {
string notification_id = 1;
string recipient_id = 2;
string tenant_id = 3;
string use_case = 4; // otp|txn|marketing|alert|conversational
string msisdn = 5; // E.164; hashed server-side
string body = 6;
int32 segments = 7;
string encoding = 8; // GSM7|UCS2
string sender_id = 9;
repeated Channel requested_channels = 10; // optional override
string idempotency_key = 11;
map<string, string> metadata = 12;
}

message RouteWithFallbackAck {
string execution_id = 1;
repeated Channel ladder_accepted = 2;
repeated ExclusionReason excluded = 3;
int32 estimated_duration_seconds = 4;
}

message ExclusionReason {
Channel channel = 1;
string reason = 2; // recipient_opt_out|compliance_block|adapter_circuit_open|no_link|cost_cap_breach
string detail = 3;
}

enum Channel {
CHANNEL_UNSPECIFIED = 0;
SMS = 1;
WHATSAPP = 2;
TELEGRAM = 3;
VIBER = 4;
VOICE = 5;
EMAIL = 6;
}

message DeliverNowRequest {
string notification_id = 1;
string tenant_id = 2;
string msisdn = 3;
Channel channel = 4;
string body = 5;
string sender_id = 6;
string template_ref = 7; // required for WhatsApp template sends
string idempotency_key = 8;
}

message DeliverNowAck {
string attempt_id = 1;
string provider_message_id = 2;
string status = 3; // accepted|sent|failed_temp|failed_perm
string reason = 4;
}

message GetConversationSessionRequest {
string tenant_id = 1;
string sender_id = 2;
string msisdn = 3;
}

message ConversationSession {
string conversation_id = 1;
string tenant_id = 2;
string sender_id = 3;
string msisdn_hash = 4;
string status = 5; // OPEN|CLOSED_STOP|CLOSED_IDLE|CLOSED_MANUAL
int32 turn_count = 6;
string opened_at = 7;
string last_seen_at = 8;
string expires_at = 9;
}

message GetRecipientProfileRequest {
string tenant_id = 1;
string msisdn = 2;
}

message RecipientProfile {
string profile_id = 1;
string msisdn_hash = 2;
repeated ChannelCapability capabilities = 3;
string discovery_state = 4;
string last_observed_at = 5;
}

message ChannelCapability {
Channel channel = 1;
string tri_state = 2; // KNOWN_YES|KNOWN_NO|UNKNOWN
float score = 3; // 0..100
float confidence = 4; // 0..1
string last_observed_at = 5;
}

1.1 gRPC error mapping

gRPC statusConditionOrchestrator/caller behaviour
OKAck returnedContinue async; subscribe to notification.delivery.outcome.v1
INVALID_ARGUMENTMissing/malformed fieldAck; mark orchestrator message BLOCKED_BAD_INPUT
FAILED_PRECONDITIONREFUSED_NO_CHANNEL, REFUSED_COST_CAP, REFUSED_SENDER_UNAUTHORIZEDAck; outcome event has already been emitted synchronously
RESOURCE_EXHAUSTEDPer-pod concurrency capDo NOT ack; NATS redelivers
UNAVAILABLEConnect / deadlineDo NOT ack
INTERNALHandler exceptionDo NOT ack
DEADLINE_EXCEEDED> orchestrator deadline (default 500 ms)Do NOT ack

After maxRedeliveries = 5, orchestrator moves the message to notification.dispatch.deadletter.v1 with reason channel_router_unavailable.


2. REST plane — admin & tenant portal

Base path: /v1/channel. Kong fronts with jwt + rate-limiting-advanced. All responses JSON envelope. Idempotency via Idempotency-Key header on mutating endpoints.

2.1 Fallback-policy CRUD (admin / tenant-admin)

MethodPathRolePurpose
GET/v1/channel/tenants/{tenantId}/policiesplatform.channel.admin or tenant.adminList policies
GET/v1/channel/tenants/{tenantId}/policies/{useCase}sameRead policy
PUT/v1/channel/tenants/{tenantId}/policies/{useCase}tenant.adminUpsert policy (validates ladder, cost cap). Idempotent.
DELETE/v1/channel/tenants/{tenantId}/policies/{useCase}tenant.adminSoft-delete; falls back to platform default. 24 h grace.
GET/v1/channel/tenants/{tenantId}/policies/{useCase}/historysameVersion history

Upsert body:

{
"strategy": "SEQUENTIAL",
"costCapPerMessage": 15.00,
"sessionTtlSeconds": 86400,
"ladder": [
{ "channel": "SMS", "deadlineSeconds": 60, "retryBudget": 1 },
{ "channel": "WHATSAPP", "deadlineSeconds": 30, "retryBudget": 0 },
{ "channel": "VOICE", "deadlineSeconds": 45, "retryBudget": 0 }
],
"stopKeywordsOverride": ["LAGO", "BAS"]
}

2.2 Channel-adapter admin (platform-admin only)

MethodPathPurpose
GET/v1/channel/adaptersList active adapters + circuit state
POST/v1/channel/tenants/{tenantId}/ott/{provider}Register OTT credentials. Secrets stored in Vault; payload returns only secretRef.
POST/v1/channel/tenants/{tenantId}/ott/{provider}/rotateRotate credential; 60 s propagation guaranteed across adapter pods via chan.ott_account.rotated.v1.
POST/v1/channel/adapters/{adapter}/circuitManually open/close/half-open breaker. Body: `{ action: "open"
GET/v1/channel/adapters/{adapter}/healthCurrent breaker state, rate-bucket occupancy, webhook last-seen.
PUT/v1/channel/adapters/{adapter}/status-mapUpsert per-provider status mapping.

2.3 Recipient-profile admin (read-only for support)

MethodPathRolePurpose
GET/v1/channel/tenants/{tenantId}/profiles?msisdnHash=tenant.supportRead-only; raw MSISDN never returned. Tenant-scoped.
POST/v1/channel/tenants/{tenantId}/profiles/{msisdnHash}/invalidate-linktenant.adminForce a TELEGRAM / VIBER link to INVALID.

2.4 Conversation-session inspector

MethodPathRolePurpose
GET/v1/channel/sessions?tenantId=&senderId=&msisdnHash=&status=tenant.support or platform.supportCursor-paginated session list
GET/v1/channel/sessions/{conversationId}sameSingle session
POST/v1/channel/sessions/{conversationId}/closetenant.adminManual close with reason: manual

2.5 Billing report (read-only)

MethodPathPurpose
GET/v1/channel/tenants/{tenantId}/billing/attempts?since=&until=&channel=Paginated per-attempt metered events for reconciliation with billing-service

2.6 Tenant portal — MO webhook config

MethodPathRolePurpose
GET/v1/channel/tenants/{tenantId}/inbound-routestenant.adminList inbound routes
PUT/v1/channel/tenants/{tenantId}/inbound-routes/{inbound}tenant.adminRegister inbound number + webhook URL + secret. Cross-checks numbering-service.
DELETE/v1/channel/tenants/{tenantId}/inbound-routes/{inbound}tenant.adminSoft-delete with 24 h grace
POST/v1/channel/tenants/{tenantId}/webhook/rotatetenant.adminRotate HMAC secret; 24 h grace accepting both

2.7 OTT inbound webhooks (provider → platform)

MethodPathVerificationPurpose
POST/v1/webhooks/whatsappX-Hub-Signature-256 (Meta app secret)Status callbacks + inbound messages for WhatsApp
POST/v1/webhooks/telegram/{secretPath}Secret-path + optional X-Telegram-Bot-Api-Secret-TokenTelegram updates
POST/v1/webhooks/viberX-Viber-Content-SignatureViber events

Invalid signatures return 401 SIGNATURE_INVALID and increment chan_webhook_signature_invalid_total{provider}.

2.8 Tenant conversation subscription (SSE)

GET /v1/channel/tenants/{tenantId}/conversations/stream — server-sent events for live conversation state (used by tenant support consoles). Authenticated via JWT; RLS-scoped.

2.9 Internal / operational

MethodPathPurpose
GET/health/live, /health/readyk8s probes
GET/metricsPrometheus
GET/v1/channel/schemas/{artifact}OpenAPI / proto schemas

2.10 Pagination

All list endpoints use cursor pagination: ?cursor=<opaque>&limit=<1..100>. Response envelope:

{ "items": [...], "nextCursor": "eyJ..." , "total": 1234 }

3. Error envelope (REST)

{
"error": {
"code": "CHAN_POLICY_UNREACHABLE_COST_CAP",
"message": "Ladder cheapest path exceeds costCapPerMessage",
"details": { "cheapestCost": 18.20, "costCap": 15.00 },
"traceId": "00-abc-..."
}
}
HTTPCodeWhen
400CHAN_VALIDATION_FAILEDMalformed payload
400CHAN_INVALID_LADDER> 6 steps / duplicate channel / unreachable cost cap
401UNAUTHENTICATEDKong JWT failure
403INSUFFICIENT_SCOPERBAC
404CHAN_POLICY_NOT_FOUND, CHAN_ROUTE_NOT_FOUND, CHAN_SESSION_NOT_FOUND
409CHAN_ROUTE_COLLISIONInbound number already assigned to another tenant
409CHAN_NUMBER_NOT_LEASEDNot leased to tenant in numbering-service
422CHAN_PARALLEL_STRATEGY_INVALIDPARALLEL strategy with non-OTT channels
422CHAN_WEBHOOK_SIGNATURE_INVALIDProvider webhook signature mismatch
429RATE_LIMITEDKong rate-limit
500INTERNALUnhandled
503DEPENDENCY_UNAVAILABLEConsent-ledger / compliance / PG / Redis

Full canonical code list in docs/standards/ERROR_CODES.md §6.


4. Versioning & compatibility

  • gRPC: ghasi.sms.channel.v1. Breaking → .v2 with ≥ 90 d overlap.
  • REST: /v1/channel/*. Additive fields are non-breaking. Breaking → /v2.
  • Events carry schemaVersion per EVENT_SCHEMAS §7.
  • Adapter-status maps are data, not schema; update without deploy via PUT /v1/channel/adapters/{adapter}/status-map.

5. Rate limits & quotas (Kong)

SurfaceLimit
Admin REST (tenant-admin)300 req/min per user
Admin REST (platform-admin)600 req/min per user
Tenant-portal recipient lookup60 req/min per tenant
OTT provider webhook ingressno Kong limit (signature-gated); per-IP 2000 req/s
gRPC RouteWithFallbackno Kong (internal mTLS); per-pod concurrency 1000

6. Contract testing

  • Pact consumer tests: sms-orchestrator → channel-router (gRPC), admin-dashboard → channel-router (REST), tenant-portal → channel-router (REST).
  • Schema evolution lint in CI: buf breaking on .proto; oasdiff on OpenAPI document.
  • Provider-webhook signature test fixtures maintained for WhatsApp, Telegram, Viber in test/fixtures/webhooks/*.