Skip to main content

sender-id-registry-service — API Contracts

Version: 1.0 Status: Draft Owner: Trust & Safety + Regulator-facing Last Updated: 2026-04-21 Companion: SYNC_CONTRACT · APPLICATION_LOGIC · SECURITY_MODEL · docs/standards/ERROR_CODES.md

The service exposes three interface planes:

  1. gRPC on :50091 (mTLS required) — Verify and GetReputation for hot-path consumers (compliance-engine, routing-engine, sms-firewall-service).
  2. HTTPS REST on :3091 — fronted by Kong; JWT + RBAC — for tenant submission, customer-portal, admin-dashboard reviewer workbench, regulator-portal-service.
  3. Public REST on :3091 (same listener, distinct route group) — anonymous, edge-cached, citizen public search.

Both REST surfaces share the same domain aggregates but serve distinct actors.


1. gRPC service — SenderIdRegistryService.v1

Full .proto lives in SYNC_CONTRACT §3. Reproduced here for reference with field-level stability notes.

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

service SenderIdRegistryService {
// Hot path. P95 ≤ 5 ms, P99 ≤ 15 ms. mTLS required.
rpc Verify(VerifyRequest) returns (VerifyResponse);

// Hot path. P95 ≤ 5 ms. mTLS required.
rpc GetReputation(GetReputationRequest) returns (GetReputationResponse);

// Bulk lookup (warm). P95 ≤ 50 ms for ≤ 100 ids. mTLS required.
rpc BatchVerify(BatchVerifyRequest) returns (BatchVerifyResponse);
}

message VerifyRequest {
string sender_id = 1; // normalised value
SenderIdType type = 2;
string tenant_id = 3; // expected owning tenant
string trace_id = 4;
}

message VerifyResponse {
RegistryStatus status = 1;
VerificationLevel current_level = 2;
bool has_domain_dns = 3; // orthogonal level flag
google.protobuf.Timestamp last_verified_at = 4;
int32 reputation_score = 5; // 0-100; 50 if no snapshot
string restricted_category = 6; // "" if not restricted
bool meets_required_level = 7;
string registrant_org_name = 8; // empty if status=UNKNOWN
}

message GetReputationRequest {
string sender_id = 1;
SenderIdType type = 2;
bool include_trend_90d = 3;
}

message GetReputationResponse {
int32 score = 1;
google.protobuf.Timestamp last_computed_at = 2;
repeated DailyScore trend_90d = 3;
}

message DailyScore { string date = 1; int32 score = 2; }

message BatchVerifyRequest {
repeated VerifyRequest items = 1; // ≤ 100
}

message BatchVerifyResponse {
repeated VerifyResponse items = 1; // same order, same length
}

enum SenderIdType {
SENDER_ID_TYPE_UNSPECIFIED = 0;
ALPHA = 1;
SHORT = 2;
LONG = 3;
}

enum RegistryStatus {
REGISTRY_STATUS_UNSPECIFIED = 0;
ACTIVE = 1;
SUSPENDED = 2;
REVOKED = 3;
UNKNOWN = 4; // not registered
TENANT_MISMATCH = 5; // registered to a different tenant — likely impersonation
PENDING = 6; // submitted but not yet ACTIVE
}

enum VerificationLevel {
VERIFICATION_LEVEL_UNSPECIFIED = 0;
NONE = 1;
OTP = 2;
DOCUMENT = 3;
NOTARISED = 4;
}

Error mapping

gRPC statusConditionCaller behaviour
OKVerdict returnedAct per status
INVALID_ARGUMENTMissing required field; malformed typeReject; log; do not retry
RESOURCE_EXHAUSTEDPer-pod concurrency cap hitRetry with backoff; do not ACK upstream NATS
UNAVAILABLEConnect or deadlineRetry per fail-closed lane policy
INTERNALHandler exceptionRetry per fail-closed lane policy
DEADLINE_EXCEEDED> 50 ms (caller default deadline)Retry per fail-closed lane policy

Caller fail-closed posture. compliance-engine SENDER_ID rule treats any error or RegistryStatus ∈ {UNKNOWN, TENANT_MISMATCH, SUSPENDED, REVOKED} as a non-allow signal and follows the rule's configured action (typically HOLD for OTP/transactional, BLOCK for restricted-category mismatches).

Streaming variants (future, not in v1)

  • WatchSenderIdChanges (server-streaming) for downstream cache invalidation — currently delivered via NATS sender.id.cache.invalidate.

2. REST — Tenant + Admin surface

Base path: /v1/sender-ids (tenant) and /v1/admin/sender-ids (admin). All endpoints fronted by Kong (jwt plugin + rate-limiting-advanced). All responses JSON. Mutating endpoints accept Idempotency-Key header.

2.1 Tenant submission & verification

MethodPathRolePurpose
POST/v1/sender-idsTenant sms:sid:writeSubmit a registration with KYC docs (UC-01)
GET/v1/sender-idsTenant sms:sid:readList own tenant's sender-IDs (paginated)
GET/v1/sender-ids/{id}Tenant sms:sid:readDetail; KYC doc list (signed URLs scoped to owner)
POST/v1/sender-ids/{id}/kyc-docsTenant sms:sid:writeAdd a KYC document post-submission (e.g. response to REQUEST_INFO)
POST/v1/sender-ids/{id}/verificationsTenant sms:sid:writeInitiate a verification challenge { method } (UC-04, UC-05)
POST/v1/sender-ids/{id}/verifications/{verificationId}/submitTenant sms:sid:writeSubmit an OTP code
POST/v1/sender-ids/{id}/verifications/{verificationId}/checkTenant sms:sid:writeTrigger DNS-TXT check
GET/v1/sender-ids/{id}/verificationsTenant sms:sid:readList verification attempts

Submission body (UC-01):

{
"value": "BANK-XYZ",
"type": "ALPHA",
"category": "BANKING",
"registrantOrgName": "Da Afghanistan Bank",
"registrantContactEmail": "compliance@example.af",
"registrantContactMsisdn": "+93701234567",
"kycDocs": [
{
"docType": "REGULATOR_LETTER",
"signedUrl": "https://uploads.ghasi.local/...",
"sha256Hex": "f1d2...",
"sizeBytes": 482312,
"mimeType": "application/pdf"
},
{
"docType": "NOTARISED_AUTHORITY",
"signedUrl": "https://uploads.ghasi.local/...",
"sha256Hex": "9ab3...",
"sizeBytes": 612844,
"mimeType": "application/pdf"
}
],
"requestedDomain": "dab.gov.af"
}

Submission response (201 Created):

{
"senderIdInternalId": "sid_01H...",
"value": "BANK-XYZ",
"type": "ALPHA",
"state": "SUBMITTED",
"requiredVerificationLevel": "NOTARISED",
"restrictedPatternMatched": {
"patternId": "rp_01H...",
"category": "BANK",
"regulatorRef": "ATRA-CIRC-2024-117"
},
"currentVerificationLevel": "NONE",
"kycDocs": [
{ "kycDocId": "kyc_01H...", "docType": "REGULATOR_LETTER", "verificationOutcome": "PENDING" }
],
"createdAt": "2026-04-21T08:11:23Z",
"_links": {
"self": "/v1/sender-ids/sid_01H...",
"verify": "/v1/sender-ids/sid_01H.../verifications"
}
}

2.2 Admin reviewer workbench

MethodPathRolePurpose
GET/v1/admin/sender-idsplatform.sid.reviewer or higherReviewer queue with filters
GET/v1/admin/sender-ids/{id}reviewer+Detail incl. KYC links
POST/v1/admin/sender-ids/{id}/claimreviewerReviewer self-claim (atomic)
POST/v1/admin/sender-ids/{id}/decisionreviewer{ action: APPROVE | REJECT | REQUEST_INFO, reason, missingDocTypes? }
GET/v1/admin/sender-ids/{id}/kyc-docs/{kycDocId}/viewreviewerWatermarked inline view URL (15-min TTL)
POST/v1/admin/sender-ids/{id}/verifications/{verificationId}/notarised-approvereviewerPrimary review of notarised verification (UC-06)
POST/v1/admin/sender-ids/{id}/verifications/{verificationId}/notarised-co-approvereviewer (different user)Dual-control second approval
POST/v1/admin/sender-ids/{id}/activateplatform.sid.adminVERIFIED → ACTIVE
POST/v1/admin/sender-ids/{id}/suspendplatform.sid.admin{ reason } (US-SID-011)
POST/v1/admin/sender-ids/{id}/reactivateplatform.sid.admin{ reason, remediationEvidenceUrl } (US-SID-013)
POST/v1/admin/sender-ids/{id}/revokeplatform.sid.admin{ reason } (US-SID-014)
GET/v1/admin/sender-ids/{id}/auditplatform.auditor or adminAudit trail (cursor pagination)
GET/v1/admin/sender-ids/{id}/reputation/historyreviewer+?days=90 (US-SID-019)

Reviewer queue response shape:

{
"items": [
{
"senderIdInternalId": "sid_01H...",
"value": "BANK-XYZ",
"type": "ALPHA",
"category": "BANKING",
"state": "SUBMITTED",
"tenantId": "t_...",
"registrantOrgName": "Da Afghanistan Bank",
"submittedAt": "2026-04-21T08:11:23Z",
"slaCountdownSeconds": 432100,
"slaBreached": false,
"restrictedPatternId": "rp_...",
"claimedBy": null,
"kycDocCount": 2,
"lastDecisionAt": null
}
],
"nextCursor": "eyJ...",
"total": 47
}

2.3 Restricted-pattern admin

MethodPathRolePurpose
GET/v1/admin/restricted-patternsplatform.sid.adminList all (active + inactive)
POST/v1/admin/restricted-patternsplatform.sid.adminCreate pattern; re2 compile + ReDoS screen at save time
PUT/v1/admin/restricted-patterns/{id}platform.sid.adminUpdate (bumps version)
POST/v1/admin/restricted-patterns/{id}/disableplatform.sid.adminSoft-disable (idempotent)
GET/v1/admin/restricted-patterns/auditplatform.auditorAudit trail

2.4 Regulator export

MethodPathRolePurpose
POST/v1/admin/sender-ids/exportmTLS regulator role OR platform.sid.adminOn-demand export (UC-14)
GET/v1/admin/sender-ids/exportmTLS regulator roleLatest export signed URL
GET/v1/admin/sender-ids/exportsplatform.auditorHistory of exports
GET/v1/admin/sender-ids/exports/{exportId}/downloadmTLS regulator rolePre-signed URL redirect

2.5 Public surface

MethodPathRolePurpose
GET/v1/sender-ids/public/searchNone (public)Citizen lookup (UC-13). Query: ?q=
GET/v1/sender-ids/public/{senderIdValue}/{type}None (public)Single sender-ID public view

Public response (US-SID-015):

{
"value": "BANK-XYZ",
"type": "ALPHA",
"registrantOrgName": "Da Afghanistan Bank",
"verificationLevel": "NOTARISED",
"hasDomainDns": true,
"status": "ACTIVE",
"registeredAt": "2025-11-04T10:00:00Z",
"verifiedNote": "This sender ID has been verified by Ghasi as belonging to Da Afghanistan Bank. Messages from this sender ID are authoritative."
}

2.6 Internal / operational

MethodPathCallerPurpose
GET/health/live, /health/readyKubernetesLiveness / readiness
GET/metricsPrometheusScrape
GET/v1/sid/openapi.jsonany authenticatedOpenAPI 3.1

3. Error shape (REST)

Uniform envelope across all REST endpoints (per docs/standards/ERROR_CODES.md):

{
"error": {
"code": "SID_RESTRICTED_REQUIREMENTS_UNMET",
"message": "Restricted name requires NOTARISED verification and a regulator letter",
"details": {
"matchedPattern": "BANK*",
"requiredDocTypes": ["REGULATOR_LETTER", "NOTARISED_AUTHORITY"],
"providedDocTypes": ["COMMERCIAL_LICENCE"]
},
"traceId": "00-abc-…"
}
}
HTTPCodeWhen
400SID_VALUE_INVALIDValue shape fails per type
400SID_REQUEST_INVALIDGeneric payload invalid
401UNAUTHENTICATEDMissing/invalid JWT
403INSUFFICIENT_SCOPECaller role lacks required SID scope
404SID_NOT_FOUNDSender-ID not found
409SID_VALUE_TAKENActive or reserved-revoked
409SID_VERSION_CONFLICTOptimistic-lock collision on state transition
409SID_INVALID_STATE_TRANSITIONE.g. revoke from SUBMITTED
409SID_DUAL_CONTROL_VIOLATIONSame reviewer attempted both notarised approvals
413SID_KYC_TOO_LARGEKYC doc > 25 MB
422SID_RESTRICTED_REQUIREMENTS_UNMETRestricted-pattern matched but docs/level insufficient
422SID_KYC_HASH_MISMATCHCaller-supplied SHA-256 differs
422SID_PATTERN_REDOS_RISKRegex failed ReDoS screen
429SID_RATE_LIMITED / SID_PUBLIC_SEARCH_RATE_LIMITEDKong / per-IP rate-limit
500INTERNALUnhandled internal error
503DEPENDENCY_UNAVAILABLEPostgres / Redis / Vault / NATS / S3 unavailable

4. Versioning

  • gRPC: ghasi.sms.sid.v1. Breaking change → v2 in parallel with ≥ 90-day deprecation window.
  • REST: /v1/sender-ids/*. Additive changes are non-breaking. Breaking change → /v2/sender-ids/*.
  • schemaVersion field on events per EVENT_SCHEMAS §7.

5. Rate limits & quotas (Kong + service-layer)

SurfaceLimitEnforced at
Tenant submission POST /v1/sender-ids30 req/min per tenantKong
Tenant verification submit60 req/min per tenantKong
OTP issuance per registrant MSISDN3 req/hourService layer (Redis)
Admin REST (platform role)600 req/min per userKong
Public search per IP100 req/min, 100 RPS burstKong + service tarpit
Public search global5,000 RPS hard capKong
gRPC VerifyNo Kong limit (mTLS internal); per-pod concurrency 5,000 in-flightgRPC server
gRPC BatchVerify100 items per request; 100 req/s per callergRPC server
Regulator export on-demand6 req/hourService layer

6. OpenAPI / Proto artefacts

  • Generated OpenAPI 3.1 served at GET /v1/sid/openapi.json and published to the internal API registry on each deploy.

  • .proto versioned in this repository; generated TypeScript + Go clients published to internal package registry.

  • Contract tests (Pact) verify:

    • compliance-engine ↔ sender-id-registry-service Verify gRPC contract
    • routing-engine ↔ sender-id-registry-service Verify gRPC contract
    • sms-firewall-service ↔ sender-id-registry-service Verify gRPC contract
    • admin-dashboard ↔ sender-id-registry-service REST reviewer-workbench contract
    • regulator-portal-service ↔ sender-id-registry-service REST export contract

    All contracts run on every CI build.


7. Caching headers (public surface)

EndpointCache-Control
/v1/sender-ids/public/search?q=public, max-age=60, s-maxage=60
/v1/sender-ids/public/{value}/{type}public, max-age=300, s-maxage=300 (purged on suspend/revoke via Cloudflare API)
All admin / tenant routesprivate, no-store