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:
- gRPC on
:50091(mTLS required) —VerifyandGetReputationfor hot-path consumers (compliance-engine,routing-engine,sms-firewall-service). - HTTPS REST on
:3091— fronted by Kong; JWT + RBAC — for tenant submission, customer-portal,admin-dashboardreviewer workbench,regulator-portal-service. - 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 status | Condition | Caller behaviour |
|---|---|---|
OK | Verdict returned | Act per status |
INVALID_ARGUMENT | Missing required field; malformed type | Reject; log; do not retry |
RESOURCE_EXHAUSTED | Per-pod concurrency cap hit | Retry with backoff; do not ACK upstream NATS |
UNAVAILABLE | Connect or deadline | Retry per fail-closed lane policy |
INTERNAL | Handler exception | Retry 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 NATSsender.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
| Method | Path | Role | Purpose |
|---|---|---|---|
| POST | /v1/sender-ids | Tenant sms:sid:write | Submit a registration with KYC docs (UC-01) |
| GET | /v1/sender-ids | Tenant sms:sid:read | List own tenant's sender-IDs (paginated) |
| GET | /v1/sender-ids/{id} | Tenant sms:sid:read | Detail; KYC doc list (signed URLs scoped to owner) |
| POST | /v1/sender-ids/{id}/kyc-docs | Tenant sms:sid:write | Add a KYC document post-submission (e.g. response to REQUEST_INFO) |
| POST | /v1/sender-ids/{id}/verifications | Tenant sms:sid:write | Initiate a verification challenge { method } (UC-04, UC-05) |
| POST | /v1/sender-ids/{id}/verifications/{verificationId}/submit | Tenant sms:sid:write | Submit an OTP code |
| POST | /v1/sender-ids/{id}/verifications/{verificationId}/check | Tenant sms:sid:write | Trigger DNS-TXT check |
| GET | /v1/sender-ids/{id}/verifications | Tenant sms:sid:read | List 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
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/sender-ids | platform.sid.reviewer or higher | Reviewer queue with filters |
| GET | /v1/admin/sender-ids/{id} | reviewer+ | Detail incl. KYC links |
| POST | /v1/admin/sender-ids/{id}/claim | reviewer | Reviewer self-claim (atomic) |
| POST | /v1/admin/sender-ids/{id}/decision | reviewer | { action: APPROVE | REJECT | REQUEST_INFO, reason, missingDocTypes? } |
| GET | /v1/admin/sender-ids/{id}/kyc-docs/{kycDocId}/view | reviewer | Watermarked inline view URL (15-min TTL) |
| POST | /v1/admin/sender-ids/{id}/verifications/{verificationId}/notarised-approve | reviewer | Primary review of notarised verification (UC-06) |
| POST | /v1/admin/sender-ids/{id}/verifications/{verificationId}/notarised-co-approve | reviewer (different user) | Dual-control second approval |
| POST | /v1/admin/sender-ids/{id}/activate | platform.sid.admin | VERIFIED → ACTIVE |
| POST | /v1/admin/sender-ids/{id}/suspend | platform.sid.admin | { reason } (US-SID-011) |
| POST | /v1/admin/sender-ids/{id}/reactivate | platform.sid.admin | { reason, remediationEvidenceUrl } (US-SID-013) |
| POST | /v1/admin/sender-ids/{id}/revoke | platform.sid.admin | { reason } (US-SID-014) |
| GET | /v1/admin/sender-ids/{id}/audit | platform.auditor or admin | Audit trail (cursor pagination) |
| GET | /v1/admin/sender-ids/{id}/reputation/history | reviewer+ | ?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
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/restricted-patterns | platform.sid.admin | List all (active + inactive) |
| POST | /v1/admin/restricted-patterns | platform.sid.admin | Create pattern; re2 compile + ReDoS screen at save time |
| PUT | /v1/admin/restricted-patterns/{id} | platform.sid.admin | Update (bumps version) |
| POST | /v1/admin/restricted-patterns/{id}/disable | platform.sid.admin | Soft-disable (idempotent) |
| GET | /v1/admin/restricted-patterns/audit | platform.auditor | Audit trail |
2.4 Regulator export
| Method | Path | Role | Purpose |
|---|---|---|---|
| POST | /v1/admin/sender-ids/export | mTLS regulator role OR platform.sid.admin | On-demand export (UC-14) |
| GET | /v1/admin/sender-ids/export | mTLS regulator role | Latest export signed URL |
| GET | /v1/admin/sender-ids/exports | platform.auditor | History of exports |
| GET | /v1/admin/sender-ids/exports/{exportId}/download | mTLS regulator role | Pre-signed URL redirect |
2.5 Public surface
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/sender-ids/public/search | None (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
| Method | Path | Caller | Purpose |
|---|---|---|---|
| GET | /health/live, /health/ready | Kubernetes | Liveness / readiness |
| GET | /metrics | Prometheus | Scrape |
| GET | /v1/sid/openapi.json | any authenticated | OpenAPI 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-…"
}
}
| HTTP | Code | When |
|---|---|---|
| 400 | SID_VALUE_INVALID | Value shape fails per type |
| 400 | SID_REQUEST_INVALID | Generic payload invalid |
| 401 | UNAUTHENTICATED | Missing/invalid JWT |
| 403 | INSUFFICIENT_SCOPE | Caller role lacks required SID scope |
| 404 | SID_NOT_FOUND | Sender-ID not found |
| 409 | SID_VALUE_TAKEN | Active or reserved-revoked |
| 409 | SID_VERSION_CONFLICT | Optimistic-lock collision on state transition |
| 409 | SID_INVALID_STATE_TRANSITION | E.g. revoke from SUBMITTED |
| 409 | SID_DUAL_CONTROL_VIOLATION | Same reviewer attempted both notarised approvals |
| 413 | SID_KYC_TOO_LARGE | KYC doc > 25 MB |
| 422 | SID_RESTRICTED_REQUIREMENTS_UNMET | Restricted-pattern matched but docs/level insufficient |
| 422 | SID_KYC_HASH_MISMATCH | Caller-supplied SHA-256 differs |
| 422 | SID_PATTERN_REDOS_RISK | Regex failed ReDoS screen |
| 429 | SID_RATE_LIMITED / SID_PUBLIC_SEARCH_RATE_LIMITED | Kong / per-IP rate-limit |
| 500 | INTERNAL | Unhandled internal error |
| 503 | DEPENDENCY_UNAVAILABLE | Postgres / Redis / Vault / NATS / S3 unavailable |
4. Versioning
- gRPC:
ghasi.sms.sid.v1. Breaking change →v2in parallel with ≥ 90-day deprecation window. - REST:
/v1/sender-ids/*. Additive changes are non-breaking. Breaking change →/v2/sender-ids/*. schemaVersionfield on events per EVENT_SCHEMAS §7.
5. Rate limits & quotas (Kong + service-layer)
| Surface | Limit | Enforced at |
|---|---|---|
Tenant submission POST /v1/sender-ids | 30 req/min per tenant | Kong |
| Tenant verification submit | 60 req/min per tenant | Kong |
| OTP issuance per registrant MSISDN | 3 req/hour | Service layer (Redis) |
| Admin REST (platform role) | 600 req/min per user | Kong |
| Public search per IP | 100 req/min, 100 RPS burst | Kong + service tarpit |
| Public search global | 5,000 RPS hard cap | Kong |
gRPC Verify | No Kong limit (mTLS internal); per-pod concurrency 5,000 in-flight | gRPC server |
gRPC BatchVerify | 100 items per request; 100 req/s per caller | gRPC server |
| Regulator export on-demand | 6 req/hour | Service layer |
6. OpenAPI / Proto artefacts
-
Generated OpenAPI 3.1 served at
GET /v1/sid/openapi.jsonand published to the internal API registry on each deploy. -
.protoversioned in this repository; generated TypeScript + Go clients published to internal package registry. -
Contract tests (Pact) verify:
compliance-engine ↔ sender-id-registry-serviceVerifygRPC contractrouting-engine ↔ sender-id-registry-serviceVerifygRPC contractsms-firewall-service ↔ sender-id-registry-serviceVerifygRPC contractadmin-dashboard ↔ sender-id-registry-serviceREST reviewer-workbench contractregulator-portal-service ↔ sender-id-registry-serviceREST export contract
All contracts run on every CI build.
7. Caching headers (public surface)
| Endpoint | Cache-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 routes | private, no-store |