Skip to main content

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:

  1. gRPC on :50073 (mTLS + SPIFFE required) — invoked by routing-engine, sms-firewall-service, compliance-engine, channel-router-service, fraud-intel-service. Hot path — ResolveMsisdn P95 ≤ 5 ms cache-hit.
  2. HTTPS REST on :3073 — fronted by Kong. Public Lookup API (billable, tenant-facing), admin MNP & EIR management, reconciliation status, adapter-admin, audit query.
  3. NATS JetStream — emits NUMBER_INTELLIGENCE_EVENTS subjects; 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 statusConditionCaller behaviour
OKLookup succeeded (any confidence class)Act on response; consult confidence before critical decisions
INVALID_ARGUMENTMalformed E.164, bad IMEI (Luhn), unknown enumNever retry; caller fixes
NOT_FOUNDNot used. Unknown MSISDN returns OK with line_type=UNKNOWN, confidence=UNKNOWN
PERMISSION_DENIEDSPIFFE SAN not in allowlist for caller planeCaller should not exist
RESOURCE_EXHAUSTEDPer-pod concurrency cap (10 000 in-flight)Exponential backoff + retry
DEADLINE_EXCEEDED> 1 s default deadlineRetry with fresh deadline
UNAVAILABLEBoth PG + Redis + prefix-table unavailable (rare)Apply caller-side fallback; do not retry aggressively
INTERNALUnhandled exceptionRetry 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-engine
  • spiffe://ghasi.platform/ns/firewall/sa/sms-firewall-service
  • spiffe://ghasi.platform/ns/compliance/sa/compliance-engine
  • spiffe://ghasi.platform/ns/router/sa/channel-router-service
  • spiffe://ghasi.platform/ns/fraud/sa/fraud-intel-service
  • spiffe://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.admin role.
  • 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

MethodPathRole / ScopePurpose
GET/v1/lookup/{msisdn}Tenant numint:lookupSingle lookup — billed as lookup.v1 or lookup.fresh.v1
POST/v1/lookup/batchTenant numint:lookupUp to 100 MSISDNs per call
POST/v1/lookup/bulk-csvTenant numint:lookup_bulkAsync 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

MethodPathRolePurpose
GET/v1/admin/numint/mnp/runsplatform.numint.admin, platform.regulatorRecent MNP reconciliation runs
POST/v1/admin/numint/mnp/runsplatform.numint.adminTrigger on-demand MNP ingest for a MNO; body { mnoId, fileUrl }. Returns 202 + Location header
GET/v1/admin/numint/mnp/runs/{runId}platform.numint.adminStatus
GET/v1/admin/numint/mnp/conflictsplatform.numint.adminPending conflicts
POST/v1/admin/numint/mnp/conflicts/{conflictId}/resolveplatform.numint.adminBody { resolution, note }
GET/v1/admin/numint/mnp/chain/verifyplatform.numint.admin, platform.regulatorHash-chain verification report (per MNO)
GET/v1/admin/numint/mnp/history/{msisdn}platform.numint.admin, platform.regulatorPer-MSISDN MNP history

2.3 Admin — EIR

MethodPathRolePurpose
GET/v1/admin/numint/eir/runsplatform.numint.adminRecent EIR sync runs
POST/v1/admin/numint/eir/runsplatform.numint.adminTrigger EIR resync
GET/v1/admin/numint/eir/{imei}platform.numint.adminSingle IMEI lookup with full reporter list

2.4 Admin — Adapter management

MethodPathRolePurpose
GET/v1/admin/numint/adaptersplatform.numint.adminList HLR adapters (per MNO) with health
PUT/v1/admin/numint/adapters/{mnoId}platform.numint.adminUpdate adapter config (endpoint, tps, timeouts)
POST/v1/admin/numint/adapters/{mnoId}/testplatform.numint.adminSynthetic probe against adapter

2.5 Admin — Overrides (sparingly used)

MethodPathRolePurpose
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.adminRemove override; next lookup re-derives from cascade

2.6 Regulator — MNP audit

MethodPathRolePurpose
GET/v1/regulator/numint/mnp/history/{msisdn}platform.regulatorHistorical MNP status for regulator dispute
GET/v1/regulator/numint/mnp/verifyplatform.regulatorHash-chain integrity proof, per-MNO

2.7 Tenant — Lookup audit (self-service)

MethodPathRolePurpose
GET/v1/lookup/auditTenant numint:audit_readPaginated audit rows for own tenant over last 90 days

2.8 Internal / operational

MethodPathCallerPurpose
GET/health/liveKubernetesLiveness
GET/health/readyKubernetesReadiness — Redis ping + PG SELECT 1 + NATS connect + cache-warm ≥ 80 %
GET/metricsPrometheusScrape
GET/v1/numint/openapi.jsonAuthenticatedOpenAPI 3.1
GET/v1/numint/proto/numint.protoAuthenticatedgRPC 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"
}
}
HTTPCodeWhen
400INVALID_MSISDNBad E.164
400INVALID_IMEILuhn check failed
401UNAUTHENTICATEDMissing/invalid JWT
403INSUFFICIENT_SCOPECaller lacks role/scope
404JOB_NOT_FOUNDUnknown runId / jobId / conflictId
409CONFLICT_ALREADY_RESOLVEDMNP conflict already resolved
409IDEMPOTENCY_KEY_CONFLICTSame key + different payload
413PAYLOAD_TOO_LARGEBatch > 100 / bulk > 1 M
422BUSINESS_RULE_VIOLATIONOverride attempt without second-approver header
429QUOTA_EXCEEDEDRPS or monthly quota exceeded; Retry-After header included
500INTERNALUnhandled internal error
503DEPENDENCY_UNAVAILABLEPostgres + 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 → v2 parallel for ≥ 90 days.
  • REST: /v1/lookup/*, /v1/admin/numint/*. Additive non-breaking; breaking → /v2/....
  • Event subjects: numint.<topic>.v1 per docs/standards/EVENT_NAMING_AND_VERSIONING.md.

6. Rate limits & quotas (Kong)

SurfaceLimit
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/batch1 req/s per tenant; max 100 entries — batch metered per successful entry
Public Lookup bulk CSV2 jobs/hour per tenant; max 1 M rows/file
Admin endpoints60 req/min per user
Regulator endpoints60 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.
  • .proto published at GET /v1/numint/proto/numint.proto.
  • Generated clients (TS, Go, Python, Java) published to internal package registry.
  • Pact contracts verify routing-engine ↔ numint gRPC and tenant-portal ↔ numint REST on every CI run (see TESTING_STRATEGY §4).