Skip to main content

numbering-service — API Contracts

Version: 1.0 Status: Draft Owner: Commerce Engineering + Platform Engineering Last Updated: 2026-04-21 Companion: SYNC_CONTRACT · APPLICATION_LOGIC · SECURITY_MODEL

The numbering-service exposes two interface planes:

  1. gRPC on :50061 (mTLS required) — hot path for sms-orchestrator.ValidateLease, routing-engine Lookup, sender-id-registry IsLeaseActive, and internal service-to-service Reserve/Assign/Release. This is the lowest-latency surface.
  2. HTTPS REST on :3021 — fronted by Kong (jwt + rate-limiting-advanced) for admin-dashboard (pool & block management), customer-portal (tenant self-service), and ATRA regulator-view endpoints.

Both planes share the same domain aggregates defined in DOMAIN_MODEL.


1. gRPC Service — NumberingService.v1

Full .proto in SYNC_CONTRACT §3.

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

service NumberingService {
// Hot path — P95 ≤ 20 ms cache-hit; ≤ 50 ms PG fallback. mTLS required.
rpc ValidateLease(ValidateLeaseRequest) returns (ValidateLeaseResponse);

// Metadata lookup for routing-engine / number-intelligence. No tenant scope.
rpc Lookup(LookupRequest) returns (LookupResponse);

// Transactional lifecycle operations — mTLS + caller allowlist enforced.
rpc Reserve(ReserveRequest) returns (ReserveResponse);
rpc Assign(AssignRequest) returns (AssignResponse);
rpc Release(ReleaseRequest) returns (ReleaseResponse);
rpc Recall(RecallRequest) returns (RecallResponse);
rpc Lookup_Lease(LookupLeaseRequest) returns (LookupLeaseResponse);
}

message ValidateLeaseRequest {
string identifier = 1; // E.164 / short-code / alpha-ID literal
NumberType type = 2;
string tenant_id = 3;
}

message ValidateLeaseResponse {
bool valid = 1;
string reason_code = 2; // NOT_REGISTERED | WRONG_TENANT | LEASE_SUSPENDED | LEASE_EXPIRED | QUARANTINE_ACTIVE | INVALID_STATE
string lease_id = 3;
google.protobuf.Timestamp effective_until = 4;
int32 version = 5; // For optimistic consistency checks
}

message LookupRequest {
string identifier = 1;
NumberType type = 2;
}

message LookupResponse {
string number_id = 1;
string value = 2;
NumberType type = 3;
NumberSubtype subtype = 4;
NumberState state = 5;
string operator_id = 6;
string mcc = 7; // '412' for Afghanistan
string mnc = 8; // per-operator
string lease_contract_id = 9;
string assigned_tenant_id = 10; // INTERNAL callers only
string assigned_lease_id = 11;
google.protobuf.Timestamp effective_until = 12;
int32 version = 13;
}

message ReserveRequest {
string identifier = 1;
NumberType type = 2;
string tenant_id = 3;
ReservationKind kind = 4; // RESERVE | HOLD
string idempotency_key = 5;
}

message ReserveResponse {
string reservation_id = 1;
google.protobuf.Timestamp expires_at = 2;
int32 number_version = 3;
}

message AssignRequest {
string identifier = 1;
NumberType type = 2;
string tenant_id = 3;
LeaseTerm term = 4; // P7D | P30D | P90D | P1Y | P3Y
bool auto_renew = 5;
bool vanity_flag = 6;
string account_id = 7;
string idempotency_key = 8;
}

message AssignResponse {
string lease_id = 1;
google.protobuf.Timestamp effective_from = 2;
google.protobuf.Timestamp effective_until = 3;
int32 number_version = 4;
}

message ReleaseRequest {
string identifier = 1;
NumberType type = 2;
string tenant_id = 3;
string idempotency_key = 4;
}

message ReleaseResponse { bool released = 1; }

message RecallRequest {
string identifier = 1;
NumberType type = 2;
RecallReason reason = 3; // REGULATOR_ORDER | ABUSE | NON_PAYMENT | TENANT_RELEASE | EXPIRED | PLATFORM_RECALL
string actor_user_id = 4;
string actor_service = 5;
string ticket_id = 6;
}

message RecallResponse {
google.protobuf.Timestamp available_at = 1; // quarantine end
}

enum NumberType { NUMBER_TYPE_UNSPECIFIED = 0; MSISDN = 1; SHORT_CODE = 2; ALPHA_ID = 3; }
enum NumberSubtype { SUBTYPE_UNSPECIFIED = 0; STANDARD = 1; VANITY = 2; TOLL_FREE = 3; PREMIUM_RATE = 4; MNO_INTERNAL = 5; }
enum NumberState { STATE_UNSPECIFIED = 0; AVAILABLE = 1; RESERVED = 2; HELD = 3; LEASED = 4; SUSPENDED = 5; RECALLED = 6; QUARANTINE = 7; }
enum ReservationKind { RESERVATION_UNSPECIFIED = 0; RESERVE = 1; HOLD = 2; }
enum LeaseTerm { TERM_UNSPECIFIED = 0; P7D = 1; P30D = 2; P90D = 3; P1Y = 4; P3Y = 5; }
enum RecallReason { RECALL_UNSPECIFIED = 0; REGULATOR_ORDER = 1; ABUSE = 2; NON_PAYMENT = 3; TENANT_RELEASE = 4; EXPIRED = 5; PLATFORM_RECALL = 6; }

gRPC error mapping

gRPC statusConditionCaller behaviour
OKNormal responseContinue
INVALID_ARGUMENTMalformed identifier, bad UUID, missing fieldCaller fixes payload; no retry
NOT_FOUNDidentifier not in inventoryCaller handles per-UC (often "reject message" for orchestrator)
PERMISSION_DENIEDmTLS CN not allowlisted OR lease owned by other tenantHard fail; no retry
FAILED_PRECONDITIONInvalid state transition; quarantine active; alpha-ID not verifiedSurface error to user
ABORTEDCAS version mismatchCaller retries once with refreshed version
RESOURCE_EXHAUSTEDQuota / concurrency capCaller backs off
UNAVAILABLEPG + Redis both downCaller retries with jitter; orchestrator fail-closed
DEADLINE_EXCEEDED> 1 sCaller retries
INTERNALUnhandledRetry; alert

mTLS caller allowlist

gRPC server accepts only connections whose client certificate CN is in this set:

CNRPCs permitted
sms-orchestratorValidateLease, Lookup
routing-engineLookup
number-intelligence-serviceLookup
sender-id-registry-serviceLookup, Recall (alpha-ID revocation path)
compliance-engineRecall (tenant suspension)
billing-serviceRecall (non-payment)
customer-portal-bffReserve, Assign, Release (tenant self-service)
admin-dashboard-bffall

Enforcement via NestJS CallerAllowlistInterceptor + per-handler @RequireCaller(...) decorator.


2. REST — Admin Plane

Base path: /v1/admin/numbering. JWT role: platform.numbering.admin unless noted.

2.1 Pools & Quotas

MethodPathRolePurpose
GET/v1/admin/numbering/poolsplatform.numbering.adminList all tenant pools
GET/v1/admin/numbering/pools/{tenantId}platform.numbering.adminGet one pool
PUT/v1/admin/numbering/pools/{tenantId}platform.numbering.adminUpsert pool (quotas, allowlist, flags)
GET/v1/admin/numbering/pools/capacityplatform.numbering.admin | platform.auditorPer-block utilisation, reservation rate, projected exhaustion

2.2 Blocks & Lease Contracts

MethodPathRolePurpose
GET/v1/admin/numbering/contractsplatform.numbering.adminList MNO lease contracts
POST/v1/admin/numbering/contractsplatform.numbering.adminRegister a new MNO contract
PUT/v1/admin/numbering/contracts/{id}platform.numbering.adminUpdate (e.g. extend effectiveUntil)
POST/v1/admin/numbering/blocks/importplatform.numbering.adminMultipart upload — see UC-09. Body: {operatorId, contractId, signature, csvFile}. Returns {batchId, imported, duplicates, invalid}. Rate limit: 5/hour.
GET/v1/admin/numbering/blocks/imports/{batchId}platform.numbering.adminStatus of one import job
GET/v1/admin/numbering/blocks/imports/{batchId}/errorsplatform.numbering.adminPaginated invalid-row list

2.3 Number Admin

MethodPathRolePurpose
GET/v1/admin/numbering/numbersplatform.numbering.admin | platform.auditorList with filters (type, state, operatorId, tenantId, prefix, cursor, limit ≤ 100)
GET/v1/admin/numbering/numbers/{value}?type=...sameFull detail incl. lease + audit
POST/v1/admin/numbering/numbers/{value}/recallplatform.numbering.adminBody {reason, ticketId?}. Idempotent on Idempotency-Key.
POST/v1/admin/numbering/numbers/{value}/reinstateplatform.numbering.adminSUSPENDED → LEASED
POST/v1/admin/numbering/numbers/{value}/quarantine/releaseplatform.numbering.adminEmergency override; body requires justification (≥ 20 chars)
GET/v1/admin/numbering/numbers/{value}/auditplatform.auditor | platform.numbering.adminHash-chained lifecycle history

2.4 Regulator Exports

MethodPathRolePurpose
GET/v1/admin/numbering/regulator-exportsplatform.auditor | platform.numbering.adminList monthly exports
GET/v1/admin/numbering/regulator-exports/{exportId}sameDetail incl. signature + S3 ref
POST/v1/admin/numbering/regulator-exports:generateplatform.numbering.adminForce off-cycle generation (rare)

3. REST — Tenant Portal

Base path: /v1/portal/numbering. JWT must carry X-Tenant-Id; RLS enforced DB-side.

MethodPathRole (tenant scope)Purpose
GET/v1/portal/numbering/availablesms:numbering:readBrowse AVAILABLE pool. Filters: type, operatorId, prefix, vanity, cursor, limit ≤ 50. Includes per-row quoted price.
GET/v1/portal/numbering/poolsms:numbering:readCurrent tenant pool view (leases, reservations, holds, quotas)
POST/v1/portal/numbering/{value}/reservesms:numbering:writeBody {type}; returns {reservationId, expiresAt}
POST/v1/portal/numbering/{value}/holdsms:numbering:writePromotes RESERVED → HELD
POST/v1/portal/numbering/{value}/leasesms:numbering:writeBody {type, term, autoRenew, vanityFlag?}; returns {leaseId, effectiveFrom, effectiveUntil}
POST/v1/portal/numbering/{value}/releasesms:numbering:writeRelease own reservation/hold
POST/v1/portal/numbering/leases/{leaseId}/renewsms:numbering:writeManual early-renew (billing pre-charge)
POST/v1/portal/numbering/leases/{leaseId}/releasesms:numbering:writeTenant-initiated recall → quarantine
GET/v1/portal/numbering/leasessms:numbering:readOwn tenant's lease history

All state-changing portal endpoints honour Idempotency-Key header (24-h replay window stored in Redis num:idem:{tenantId}:{key}).


4. Error envelope (REST)

Uniform across admin + portal:

{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "Tenant has reached maxLeasedMsisdn limit",
"details": { "identifierClass": "MSISDN", "current": 50, "quota": 50 },
"traceId": "00-abc-..."
}
}
HTTPCodeWhen
400VALIDATION_FAILEDBad payload (E.164 shape, missing fields, etc.)
401UNAUTHENTICATEDJWT missing/invalid (Kong rejects)
403INSUFFICIENT_SCOPEJWT valid but role lacks scope
403QUOTA_EXCEEDEDPer-pool quota hit
403RESERVATION_QUOTAmaxActiveReservations hit
404NOT_REGISTEREDidentifier unknown
409NOT_AVAILABLEState is not AVAILABLE when expected
409HELD_BY_OTHER_TENANTReservation/hold owned by someone else
409QUARANTINE_ACTIVEBody includes availableAt
409USE_RECALL_FOR_LEASESRelease called on LEASED
409CONFLICTCAS race lost
422ALPHA_NOT_VERIFIEDsender-id-registry reports unverified
422NOT_VANITY_ELIGIBLEshort code not in vanity_eligible table
422SIGNATURE_INVALIDMNO CSV signature failed
422INVALID_TRANSITIONillegal state transition
429RATE_LIMITEDKong rate-limit
503DEPENDENCY_UNAVAILABLEPG/Redis/NATS unavailable

5. Versioning

  • gRPC: ghasi.sms.numbering.v1. Additive fields non-breaking (proto3). Breaking → v2 in parallel with 90-day deprecation.
  • REST: /v1/...; breaking → /v2/... + 90-day deprecation.
  • schemaVersion field on events (see EVENT_SCHEMAS §7).

6. Rate Limits & Quotas

SurfaceLimit
Admin REST600 req/min per user
Admin bulk (block import, bulk recall)5 req/hour
Tenant portal reads120 req/min per tenant
Tenant portal writes (Reserve/Assign)60 req/min per tenant
gRPC ValidateLease (per caller cert)No Kong limit; per-pod concurrency 1000
gRPC Lookupper-pod concurrency 500
gRPC state-mutationper-pod concurrency 200

7. Idempotency

All state-changing gRPC RPCs (Reserve, Assign, Release, Recall) and REST endpoints require / honour an idempotency_key / Idempotency-Key header.

  • Stored in Redis num:idem:{callerId}:{key} EX 86400.
  • Replay returns the cached response; body mismatch returns 409 IDEMPOTENCY_CONFLICT.
  • Keys are opaque strings ≤ 128 chars; UUIDv4 recommended.

8. OpenAPI / Proto artefacts

  • OpenAPI 3.1 served at GET /v1/numbering/openapi.json; published to internal API registry on every deploy.
  • .proto versioned in repo; TypeScript + Go clients generated and published to internal package registry.
  • Pact contract tests verify sms-orchestrator ↔ numbering-service and customer-portal ↔ numbering-service on every CI run.

End of API_CONTRACTS.md