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:
- gRPC data plane on
:50071(mTLS) —RouteWithFallback,DeliverNow,GetRecipientProfile,GetConversationSession. Consumed bysms-orchestratorand peer data-plane services. - gRPC control plane on
:50072(mTLS) — Policy + adapter admin RPCs. Consumed byadmin-dashboardanddeveloper-portal-service. - 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 status | Condition | Orchestrator/caller behaviour |
|---|---|---|
OK | Ack returned | Continue async; subscribe to notification.delivery.outcome.v1 |
INVALID_ARGUMENT | Missing/malformed field | Ack; mark orchestrator message BLOCKED_BAD_INPUT |
FAILED_PRECONDITION | REFUSED_NO_CHANNEL, REFUSED_COST_CAP, REFUSED_SENDER_UNAUTHORIZED | Ack; outcome event has already been emitted synchronously |
RESOURCE_EXHAUSTED | Per-pod concurrency cap | Do NOT ack; NATS redelivers |
UNAVAILABLE | Connect / deadline | Do NOT ack |
INTERNAL | Handler exception | Do 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)
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/channel/tenants/{tenantId}/policies | platform.channel.admin or tenant.admin | List policies |
| GET | /v1/channel/tenants/{tenantId}/policies/{useCase} | same | Read policy |
| PUT | /v1/channel/tenants/{tenantId}/policies/{useCase} | tenant.admin | Upsert policy (validates ladder, cost cap). Idempotent. |
| DELETE | /v1/channel/tenants/{tenantId}/policies/{useCase} | tenant.admin | Soft-delete; falls back to platform default. 24 h grace. |
| GET | /v1/channel/tenants/{tenantId}/policies/{useCase}/history | same | Version 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)
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/channel/adapters | List 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}/rotate | Rotate credential; 60 s propagation guaranteed across adapter pods via chan.ott_account.rotated.v1. |
| POST | /v1/channel/adapters/{adapter}/circuit | Manually open/close/half-open breaker. Body: `{ action: "open" |
| GET | /v1/channel/adapters/{adapter}/health | Current breaker state, rate-bucket occupancy, webhook last-seen. |
| PUT | /v1/channel/adapters/{adapter}/status-map | Upsert per-provider status mapping. |
2.3 Recipient-profile admin (read-only for support)
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/channel/tenants/{tenantId}/profiles?msisdnHash= | tenant.support | Read-only; raw MSISDN never returned. Tenant-scoped. |
| POST | /v1/channel/tenants/{tenantId}/profiles/{msisdnHash}/invalidate-link | tenant.admin | Force a TELEGRAM / VIBER link to INVALID. |
2.4 Conversation-session inspector
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/channel/sessions?tenantId=&senderId=&msisdnHash=&status= | tenant.support or platform.support | Cursor-paginated session list |
| GET | /v1/channel/sessions/{conversationId} | same | Single session |
| POST | /v1/channel/sessions/{conversationId}/close | tenant.admin | Manual close with reason: manual |
2.5 Billing report (read-only)
| Method | Path | Purpose |
|---|---|---|
| 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
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/channel/tenants/{tenantId}/inbound-routes | tenant.admin | List inbound routes |
| PUT | /v1/channel/tenants/{tenantId}/inbound-routes/{inbound} | tenant.admin | Register inbound number + webhook URL + secret. Cross-checks numbering-service. |
| DELETE | /v1/channel/tenants/{tenantId}/inbound-routes/{inbound} | tenant.admin | Soft-delete with 24 h grace |
| POST | /v1/channel/tenants/{tenantId}/webhook/rotate | tenant.admin | Rotate HMAC secret; 24 h grace accepting both |
2.7 OTT inbound webhooks (provider → platform)
| Method | Path | Verification | Purpose |
|---|---|---|---|
| POST | /v1/webhooks/whatsapp | X-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-Token | Telegram updates |
| POST | /v1/webhooks/viber | X-Viber-Content-Signature | Viber 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
| Method | Path | Purpose |
|---|---|---|
| GET | /health/live, /health/ready | k8s probes |
| GET | /metrics | Prometheus |
| 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-..."
}
}
| HTTP | Code | When |
|---|---|---|
| 400 | CHAN_VALIDATION_FAILED | Malformed payload |
| 400 | CHAN_INVALID_LADDER | > 6 steps / duplicate channel / unreachable cost cap |
| 401 | UNAUTHENTICATED | Kong JWT failure |
| 403 | INSUFFICIENT_SCOPE | RBAC |
| 404 | CHAN_POLICY_NOT_FOUND, CHAN_ROUTE_NOT_FOUND, CHAN_SESSION_NOT_FOUND | |
| 409 | CHAN_ROUTE_COLLISION | Inbound number already assigned to another tenant |
| 409 | CHAN_NUMBER_NOT_LEASED | Not leased to tenant in numbering-service |
| 422 | CHAN_PARALLEL_STRATEGY_INVALID | PARALLEL strategy with non-OTT channels |
| 422 | CHAN_WEBHOOK_SIGNATURE_INVALID | Provider webhook signature mismatch |
| 429 | RATE_LIMITED | Kong rate-limit |
| 500 | INTERNAL | Unhandled |
| 503 | DEPENDENCY_UNAVAILABLE | Consent-ledger / compliance / PG / Redis |
Full canonical code list in docs/standards/ERROR_CODES.md §6.
4. Versioning & compatibility
- gRPC:
ghasi.sms.channel.v1. Breaking →.v2with ≥ 90 d overlap. - REST:
/v1/channel/*. Additive fields are non-breaking. Breaking →/v2. - Events carry
schemaVersionper 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)
| Surface | Limit |
|---|---|
| Admin REST (tenant-admin) | 300 req/min per user |
| Admin REST (platform-admin) | 600 req/min per user |
| Tenant-portal recipient lookup | 60 req/min per tenant |
| OTT provider webhook ingress | no Kong limit (signature-gated); per-IP 2000 req/s |
gRPC RouteWithFallback | no 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 breakingon.proto;oasdiffon OpenAPI document. - Provider-webhook signature test fixtures maintained for WhatsApp, Telegram, Viber in
test/fixtures/webhooks/*.