Number Intelligence Service — API Contracts
Version: 1.0 Status: Draft Owner: Messaging Core Last Updated: 2026-04-21 Companion: SYNC_CONTRACT · APPLICATION_LOGIC · SECURITY_MODEL
number-intelligence-service exposes three interface planes:
- gRPC on
:50073(mTLS + SPIFFE required) — invoked byrouting-engine,sms-firewall-service,compliance-engine,channel-router-service,fraud-intel-service. Hot path —ResolveMsisdnP95 ≤ 5 ms cache-hit. - HTTPS REST on
:3073— fronted by Kong. Public Lookup API (billable, tenant-facing), admin MNP & EIR management, reconciliation status, adapter-admin, audit query. - NATS JetStream — emits
NUMBER_INTELLIGENCE_EVENTSsubjects; minimal consumption (operator.config.changed.v1,billing.tenant.plan.changed.v1). MNO MNP files arrive via SFTP, not NATS.
1. gRPC service — NumberIntelligenceService.v1
Full proto in SYNC_CONTRACT §3. Reproduced here for reference.
syntax = "proto3";
package ghasi.sms.numint.v1;
option go_package = "github.com/ghasi/sms-gateway/numint/v1";
import "google/protobuf/timestamp.proto";
service NumberIntelligenceService {
// Hot path. P95 ≤ 5 ms cache-hit; P99 ≤ 20 ms cache-miss; worst case bounded
// by live-HLR timeout when forceFresh=true.
rpc ResolveMsisdn (ResolveMsisdnRequest) returns (MsisdnAttribution);
// Streaming batch. P95 ≤ 80 ms for 500 entries at ≥ 95% cache hit.
rpc ResolveBatch (ResolveBatchRequest) returns (stream MsisdnAttribution);
// Forced live probe (admin / fraud-intel use). TPS-governed; may block
// up to tps_wait_ms for a token.
rpc ProbeHlr (ProbeHlrRequest) returns (HlrProbeResult);
// MNP-only fast-path; used by routing-engine to override prefix-table routing.
rpc LookupPorting (LookupPortingRequest) returns (PortingStatus);
// EIR device-status check; used by firewall / fraud-intel.
rpc LookupEir (LookupEirRequest) returns (EirStatus);
// Historic MNP events for a single MSISDN; admin / regulator query.
rpc GetMnpHistory (GetMnpHistoryRequest) returns (MnpHistory);
// Opportunistic MSISDN↔IMEI link from VLR observation.
rpc LookupMsisdnImei(LookupMsisdnImeiRequest) returns (MsisdnImeiLink);
}
message ResolveMsisdnRequest {
string e164 = 1; // E.164
LookupScope scope = 2; // default BASIC when unset
ResolveOpts opts = 3;
string trace_id = 4;
}
message ResolveOpts {
int32 max_staleness_seconds = 1; // default 86400 (24h)
bool force_fresh = 2; // bypass cache -> force live HLR (TPS-bounded)
int32 tps_wait_ms = 3; // default 200
bool include_eir_observation = 4;
}
message MsisdnAttribution {
string e164 = 1; // echoed for correlation
Mno mno = 2; // current owner (recipient if ported)
Mno original_mno = 3; // null if NATIVE
LineType line_type = 4;
string country = 5; // ISO 3166-1 alpha-2
MnpStatus mnp_status = 6;
string vlr = 7; // null when source != live
repeated RiskFlag risk_flags = 8;
AttributionSource source = 9;
Confidence confidence = 10;
int64 staleness_seconds = 11;
LookupLatencyClass tier = 12;
google.protobuf.Timestamp cached_at = 13;
EirObservation eir_observation = 14; // optional, per include_eir_observation
}
message Mno {
string id = 1; // slug e.g. "afghan-wireless"
string name = 2;
}
message ResolveBatchRequest {
repeated string entries = 1; // max 1000
ResolveOpts opts = 2;
string trace_id = 3;
}
message ProbeHlrRequest {
string e164 = 1;
string mno_hint = 2; // optional — derived from prefix if empty
int32 tps_wait_ms = 3;
}
message HlrProbeResult {
string probe_id = 1; // prb_<ULID>
ProbeStatus status = 2;
MsisdnAttribution snapshot = 3; // populated iff OK
int32 duration_ms = 4;
}
message LookupPortingRequest { string e164 = 1; }
message PortingStatus {
bool is_ported = 1;
Mno current_mno = 2;
Mno original_mno = 3;
google.protobuf.Timestamp port_date = 4;
AttributionSource source = 5; // mnp_registry | hlr_observation | default_unported
}
message LookupEirRequest { string imei = 1; }
message EirStatus {
EirState status = 1;
string reason_code = 2;
repeated string reported_by = 3;
google.protobuf.Timestamp last_updated = 4;
}
message GetMnpHistoryRequest { string e164 = 1; }
message MnpHistory { repeated MnpEvent events = 1; }
message MnpEvent {
string port_id = 1;
Mno donor_mno = 2; Mno recipient_mno = 3;
google.protobuf.Timestamp port_date = 4;
string source_feed = 5;
}
message LookupMsisdnImeiRequest { string e164 = 1; }
message MsisdnImeiLink {
string imei_hash = 1; // hash only — IMEI never returned
int32 observation_count = 2;
google.protobuf.Timestamp last_observed_at = 3;
Confidence confidence = 4; // always "observed" here
}
enum LookupScope { SCOPE_BASIC = 0; SCOPE_WITH_PORTING = 1; SCOPE_WITH_EIR = 2; SCOPE_FULL = 3; }
enum LineType { LT_UNKNOWN = 0; MOBILE = 1; FIXED = 2; VOIP = 3; }
enum MnpStatus { MNP_UNKNOWN = 0; NATIVE = 1; PORTED_IN = 2; PORTED_OUT = 3; }
enum Confidence { CONF_UNKNOWN = 0; HIGH = 1; MEDIUM = 2; LOW = 3; }
enum AttributionSource {
SOURCE_UNSPECIFIED = 0;
LRU = 1; REDIS = 2; POSTGRES = 3;
LIVE_HLR_MAP = 4; LIVE_HLR_REST = 5;
MNP_RECON = 6; PREFIX_FALLBACK = 7; STALE_THROTTLED = 8; ADMIN_OVERRIDE = 9;
MNO_HLR_DUMP = 10;
}
enum LookupLatencyClass { TIER_UNSPECIFIED = 0; TIER_LRU = 1; TIER_REDIS = 2; TIER_PG = 3; TIER_LIVE = 4; TIER_FALLBACK = 5; }
enum RiskFlag { RF_UNSPECIFIED = 0; STOLEN_DEVICE = 1; MNP_DIVERGENCE = 2; ABNORMAL_MNP_CHURN = 3; PREFIX_MISMATCH = 4; UNUSUAL_VLR = 5; }
enum EirState { EIR_UNKNOWN = 0; WHITELIST = 1; GREYLIST = 2; BLACKLIST = 3; }
enum ProbeStatus { PS_UNSPECIFIED = 0; OK = 1; TIMEOUT = 2; MAP_ABORT = 3; REST_5XX = 4; THROTTLED = 5; ADAPTER_DOWN = 6; }
1.1 gRPC error mapping
| gRPC status | Condition | Caller behaviour |
|---|---|---|
OK | Lookup succeeded (any confidence class) | Act on response; consult confidence before critical decisions |
INVALID_ARGUMENT | Malformed E.164, bad IMEI (Luhn), unknown enum | Never retry; caller fixes |
NOT_FOUND | Not used. Unknown MSISDN returns OK with line_type=UNKNOWN, confidence=UNKNOWN | |
PERMISSION_DENIED | SPIFFE SAN not in allowlist for caller plane | Caller should not exist |
RESOURCE_EXHAUSTED | Per-pod concurrency cap (10 000 in-flight) | Exponential backoff + retry |
DEADLINE_EXCEEDED | > 1 s default deadline | Retry with fresh deadline |
UNAVAILABLE | Both PG + Redis + prefix-table unavailable (rare) | Apply caller-side fallback; do not retry aggressively |
INTERNAL | Unhandled exception | Retry once; escalate |
Semantic NOT_FOUND. A critical invariant: unknown MSISDNs are a legitimate outcome of a public-number database, not an error. Callers MUST handle line_type=UNKNOWN without treating it as a call failure. This aligns with Twilio Lookup / Telnyx LRN industry convention.
1.2 SPIFFE SAN allowlist
The gRPC server pins these SPIFFE IDs:
spiffe://ghasi.platform/ns/routing/sa/routing-enginespiffe://ghasi.platform/ns/firewall/sa/sms-firewall-servicespiffe://ghasi.platform/ns/compliance/sa/compliance-enginespiffe://ghasi.platform/ns/router/sa/channel-router-servicespiffe://ghasi.platform/ns/fraud/sa/fraud-intel-servicespiffe://ghasi.platform/ns/orchestrator/sa/sms-orchestrator(for ResolveBatch pre-flight)spiffe://ghasi.platform/ns/gateway/sa/tenant-sdk-gateway(for tenant gRPC SDK — metered)
2. REST — public surface
Base paths:
- Tenant (Public Lookup):
/v1/lookup/...— billable; JWT + X-Tenant-Id from Kong. - Admin (MNP, EIR, adapters):
/v1/admin/numint/...—platform.numint.adminrole. - Regulator:
/v1/regulator/numint/...— read-only MNP audit.
All endpoints fronted by Kong with jwt + rate-limiting-advanced. State-changing endpoints accept Idempotency-Key (24 h replay window).
2.1 Tenant — Public Lookup API
| Method | Path | Role / Scope | Purpose |
|---|---|---|---|
| GET | /v1/lookup/{msisdn} | Tenant numint:lookup | Single lookup — billed as lookup.v1 or lookup.fresh.v1 |
| POST | /v1/lookup/batch | Tenant numint:lookup | Up to 100 MSISDNs per call |
| POST | /v1/lookup/bulk-csv | Tenant numint:lookup_bulk | Async CSV bulk upload (max 1 M rows); returns jobId; results in presigned S3 URL |
GET /v1/lookup/+93701234567 query params: maxStaleness=<seconds> (default 86400).
Response 200:
{
"msisdn": "+93701234567",
"country": "AF",
"mno": { "id": "afghan-wireless", "name": "Afghan Wireless" },
"originalMno": null,
"lineType": "MOBILE",
"mnpStatus": "NATIVE",
"isPorted": false,
"riskFlags": [],
"source": "redis",
"confidence": "high",
"tier": "redis",
"fetchedAt": "2026-04-20T12:34:56Z",
"stalenessSeconds": 137
}
POST /v1/lookup/batch request:
{ "msisdns": ["+93701234567","+93700000001"], "maxStaleness": 86400 }
Response 200 — results in input order, per-slot error shape for failures.
2.2 Admin — MNP
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/numint/mnp/runs | platform.numint.admin, platform.regulator | Recent MNP reconciliation runs |
| POST | /v1/admin/numint/mnp/runs | platform.numint.admin | Trigger on-demand MNP ingest for a MNO; body { mnoId, fileUrl }. Returns 202 + Location header |
| GET | /v1/admin/numint/mnp/runs/{runId} | platform.numint.admin | Status |
| GET | /v1/admin/numint/mnp/conflicts | platform.numint.admin | Pending conflicts |
| POST | /v1/admin/numint/mnp/conflicts/{conflictId}/resolve | platform.numint.admin | Body { resolution, note } |
| GET | /v1/admin/numint/mnp/chain/verify | platform.numint.admin, platform.regulator | Hash-chain verification report (per MNO) |
| GET | /v1/admin/numint/mnp/history/{msisdn} | platform.numint.admin, platform.regulator | Per-MSISDN MNP history |
2.3 Admin — EIR
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/numint/eir/runs | platform.numint.admin | Recent EIR sync runs |
| POST | /v1/admin/numint/eir/runs | platform.numint.admin | Trigger EIR resync |
| GET | /v1/admin/numint/eir/{imei} | platform.numint.admin | Single IMEI lookup with full reporter list |
2.4 Admin — Adapter management
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/numint/adapters | platform.numint.admin | List HLR adapters (per MNO) with health |
| PUT | /v1/admin/numint/adapters/{mnoId} | platform.numint.admin | Update adapter config (endpoint, tps, timeouts) |
| POST | /v1/admin/numint/adapters/{mnoId}/test | platform.numint.admin | Synthetic probe against adapter |
2.5 Admin — Overrides (sparingly used)
| Method | Path | Role | Purpose |
|---|---|---|---|
| POST | /v1/admin/numint/overrides/{msisdn} | platform.numint.admin (dual-control) | Manually set attribution; audit-logged; requires X-Approver-User-Id second-approver header |
| DELETE | /v1/admin/numint/overrides/{msisdn} | platform.numint.admin | Remove override; next lookup re-derives from cascade |
2.6 Regulator — MNP audit
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/regulator/numint/mnp/history/{msisdn} | platform.regulator | Historical MNP status for regulator dispute |
| GET | /v1/regulator/numint/mnp/verify | platform.regulator | Hash-chain integrity proof, per-MNO |
2.7 Tenant — Lookup audit (self-service)
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/lookup/audit | Tenant numint:audit_read | Paginated audit rows for own tenant over last 90 days |
2.8 Internal / operational
| Method | Path | Caller | Purpose |
|---|---|---|---|
| GET | /health/live | Kubernetes | Liveness |
| GET | /health/ready | Kubernetes | Readiness — Redis ping + PG SELECT 1 + NATS connect + cache-warm ≥ 80 % |
| GET | /metrics | Prometheus | Scrape |
| GET | /v1/numint/openapi.json | Authenticated | OpenAPI 3.1 |
| GET | /v1/numint/proto/numint.proto | Authenticated | gRPC proto |
3. Pagination
Cursor-based (platform standard):
{
"items": [ /* … */ ],
"nextCursor": "eyJzZXEiOjQ4MzcxMn0=",
"previousCursor": null,
"total": 1287
}
4. Error shape (REST)
Uniform envelope (docs/standards/ERROR_CODES.md):
{
"error": {
"code": "INVALID_MSISDN",
"message": "MSISDN must be E.164",
"details": { "field": "msisdn", "value": "0701234567" },
"traceId": "00-abc-def-01"
}
}
| HTTP | Code | When |
|---|---|---|
| 400 | INVALID_MSISDN | Bad E.164 |
| 400 | INVALID_IMEI | Luhn check failed |
| 401 | UNAUTHENTICATED | Missing/invalid JWT |
| 403 | INSUFFICIENT_SCOPE | Caller lacks role/scope |
| 404 | JOB_NOT_FOUND | Unknown runId / jobId / conflictId |
| 409 | CONFLICT_ALREADY_RESOLVED | MNP conflict already resolved |
| 409 | IDEMPOTENCY_KEY_CONFLICT | Same key + different payload |
| 413 | PAYLOAD_TOO_LARGE | Batch > 100 / bulk > 1 M |
| 422 | BUSINESS_RULE_VIOLATION | Override attempt without second-approver header |
| 429 | QUOTA_EXCEEDED | RPS or monthly quota exceeded; Retry-After header included |
| 500 | INTERNAL | Unhandled internal error |
| 503 | DEPENDENCY_UNAVAILABLE | Postgres + Redis + prefix-table all unreachable |
Note on NOT_FOUND semantics. REST GET /v1/lookup/{msisdn} for an unknown MSISDN returns 200 with lineType = "UNKNOWN" and confidence = "unknown" — not 404. 404 is reserved for resource-identifier lookups (runs, conflicts).
5. Versioning
- gRPC:
ghasi.sms.numint.v1. Breaking change →v2parallel for ≥ 90 days. - REST:
/v1/lookup/*,/v1/admin/numint/*. Additive non-breaking; breaking →/v2/.... - Event subjects:
numint.<topic>.v1perdocs/standards/EVENT_NAMING_AND_VERSIONING.md.
6. Rate limits & quotas (Kong)
| Surface | Limit |
|---|---|
| Public Lookup GET (tenant default plan) | 10 req/s; 100 000/month |
| Public Lookup GET (tenant pro plan) | 100 req/s; 10 M/month |
| Public Lookup GET (tenant enterprise) | custom; set via TenantLookupQuota |
Public Lookup maxStaleness < 86400 (forces live HLR) | lower sub-bucket — default 2 req/s per tenant |
Public Lookup POST /v1/lookup/batch | 1 req/s per tenant; max 100 entries — batch metered per successful entry |
| Public Lookup bulk CSV | 2 jobs/hour per tenant; max 1 M rows/file |
| Admin endpoints | 60 req/min per user |
| Regulator endpoints | 60 req/min per user |
Internal gRPC (ResolveMsisdn) | No Kong limit; per-pod in-flight 10 000 |
Per-tenant quotas are configurable via TenantLookupQuota and synchronised from billing-service (billing.tenant.plan.changed.v1).
7. Idempotency
State-changing admin endpoints accept Idempotency-Key: <uuid>; service stores (key, requestHash, response) for 24 h.
- Same key + same hash → cached response.
- Same key + different hash →
409 IDEMPOTENCY_KEY_CONFLICT.
Bulk-CSV uploads use jobId (UUID) as de-facto idempotency.
8. OpenAPI / Proto artefacts
- OpenAPI 3.1 served at
GET /v1/numint/openapi.json. .protopublished atGET /v1/numint/proto/numint.proto.- Generated clients (TS, Go, Python, Java) published to internal package registry.
- Pact contracts verify
routing-engine ↔ numintgRPC andtenant-portal ↔ numintREST on every CI run (see TESTING_STRATEGY §4).