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:
- gRPC on
:50061(mTLS required) — hot path forsms-orchestrator.ValidateLease, routing-engineLookup, sender-id-registryIsLeaseActive, and internal service-to-serviceReserve/Assign/Release. This is the lowest-latency surface. - 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 status | Condition | Caller behaviour |
|---|---|---|
OK | Normal response | Continue |
INVALID_ARGUMENT | Malformed identifier, bad UUID, missing field | Caller fixes payload; no retry |
NOT_FOUND | identifier not in inventory | Caller handles per-UC (often "reject message" for orchestrator) |
PERMISSION_DENIED | mTLS CN not allowlisted OR lease owned by other tenant | Hard fail; no retry |
FAILED_PRECONDITION | Invalid state transition; quarantine active; alpha-ID not verified | Surface error to user |
ABORTED | CAS version mismatch | Caller retries once with refreshed version |
RESOURCE_EXHAUSTED | Quota / concurrency cap | Caller backs off |
UNAVAILABLE | PG + Redis both down | Caller retries with jitter; orchestrator fail-closed |
DEADLINE_EXCEEDED | > 1 s | Caller retries |
INTERNAL | Unhandled | Retry; alert |
mTLS caller allowlist
gRPC server accepts only connections whose client certificate CN is in this set:
| CN | RPCs permitted |
|---|---|
sms-orchestrator | ValidateLease, Lookup |
routing-engine | Lookup |
number-intelligence-service | Lookup |
sender-id-registry-service | Lookup, Recall (alpha-ID revocation path) |
compliance-engine | Recall (tenant suspension) |
billing-service | Recall (non-payment) |
customer-portal-bff | Reserve, Assign, Release (tenant self-service) |
admin-dashboard-bff | all |
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
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/numbering/pools | platform.numbering.admin | List all tenant pools |
| GET | /v1/admin/numbering/pools/{tenantId} | platform.numbering.admin | Get one pool |
| PUT | /v1/admin/numbering/pools/{tenantId} | platform.numbering.admin | Upsert pool (quotas, allowlist, flags) |
| GET | /v1/admin/numbering/pools/capacity | platform.numbering.admin | platform.auditor | Per-block utilisation, reservation rate, projected exhaustion |
2.2 Blocks & Lease Contracts
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/numbering/contracts | platform.numbering.admin | List MNO lease contracts |
| POST | /v1/admin/numbering/contracts | platform.numbering.admin | Register a new MNO contract |
| PUT | /v1/admin/numbering/contracts/{id} | platform.numbering.admin | Update (e.g. extend effectiveUntil) |
| POST | /v1/admin/numbering/blocks/import | platform.numbering.admin | Multipart 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.admin | Status of one import job |
| GET | /v1/admin/numbering/blocks/imports/{batchId}/errors | platform.numbering.admin | Paginated invalid-row list |
2.3 Number Admin
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/numbering/numbers | platform.numbering.admin | platform.auditor | List with filters (type, state, operatorId, tenantId, prefix, cursor, limit ≤ 100) |
| GET | /v1/admin/numbering/numbers/{value}?type=... | same | Full detail incl. lease + audit |
| POST | /v1/admin/numbering/numbers/{value}/recall | platform.numbering.admin | Body {reason, ticketId?}. Idempotent on Idempotency-Key. |
| POST | /v1/admin/numbering/numbers/{value}/reinstate | platform.numbering.admin | SUSPENDED → LEASED |
| POST | /v1/admin/numbering/numbers/{value}/quarantine/release | platform.numbering.admin | Emergency override; body requires justification (≥ 20 chars) |
| GET | /v1/admin/numbering/numbers/{value}/audit | platform.auditor | platform.numbering.admin | Hash-chained lifecycle history |
2.4 Regulator Exports
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/numbering/regulator-exports | platform.auditor | platform.numbering.admin | List monthly exports |
| GET | /v1/admin/numbering/regulator-exports/{exportId} | same | Detail incl. signature + S3 ref |
| POST | /v1/admin/numbering/regulator-exports:generate | platform.numbering.admin | Force off-cycle generation (rare) |
3. REST — Tenant Portal
Base path: /v1/portal/numbering. JWT must carry X-Tenant-Id; RLS enforced DB-side.
| Method | Path | Role (tenant scope) | Purpose |
|---|---|---|---|
| GET | /v1/portal/numbering/available | sms:numbering:read | Browse AVAILABLE pool. Filters: type, operatorId, prefix, vanity, cursor, limit ≤ 50. Includes per-row quoted price. |
| GET | /v1/portal/numbering/pool | sms:numbering:read | Current tenant pool view (leases, reservations, holds, quotas) |
| POST | /v1/portal/numbering/{value}/reserve | sms:numbering:write | Body {type}; returns {reservationId, expiresAt} |
| POST | /v1/portal/numbering/{value}/hold | sms:numbering:write | Promotes RESERVED → HELD |
| POST | /v1/portal/numbering/{value}/lease | sms:numbering:write | Body {type, term, autoRenew, vanityFlag?}; returns {leaseId, effectiveFrom, effectiveUntil} |
| POST | /v1/portal/numbering/{value}/release | sms:numbering:write | Release own reservation/hold |
| POST | /v1/portal/numbering/leases/{leaseId}/renew | sms:numbering:write | Manual early-renew (billing pre-charge) |
| POST | /v1/portal/numbering/leases/{leaseId}/release | sms:numbering:write | Tenant-initiated recall → quarantine |
| GET | /v1/portal/numbering/leases | sms:numbering:read | Own 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-..."
}
}
| HTTP | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Bad payload (E.164 shape, missing fields, etc.) |
| 401 | UNAUTHENTICATED | JWT missing/invalid (Kong rejects) |
| 403 | INSUFFICIENT_SCOPE | JWT valid but role lacks scope |
| 403 | QUOTA_EXCEEDED | Per-pool quota hit |
| 403 | RESERVATION_QUOTA | maxActiveReservations hit |
| 404 | NOT_REGISTERED | identifier unknown |
| 409 | NOT_AVAILABLE | State is not AVAILABLE when expected |
| 409 | HELD_BY_OTHER_TENANT | Reservation/hold owned by someone else |
| 409 | QUARANTINE_ACTIVE | Body includes availableAt |
| 409 | USE_RECALL_FOR_LEASES | Release called on LEASED |
| 409 | CONFLICT | CAS race lost |
| 422 | ALPHA_NOT_VERIFIED | sender-id-registry reports unverified |
| 422 | NOT_VANITY_ELIGIBLE | short code not in vanity_eligible table |
| 422 | SIGNATURE_INVALID | MNO CSV signature failed |
| 422 | INVALID_TRANSITION | illegal state transition |
| 429 | RATE_LIMITED | Kong rate-limit |
| 503 | DEPENDENCY_UNAVAILABLE | PG/Redis/NATS unavailable |
5. Versioning
- gRPC:
ghasi.sms.numbering.v1. Additive fields non-breaking (proto3). Breaking →v2in parallel with 90-day deprecation. - REST:
/v1/...; breaking →/v2/...+ 90-day deprecation. schemaVersionfield on events (see EVENT_SCHEMAS §7).
6. Rate Limits & Quotas
| Surface | Limit |
|---|---|
| Admin REST | 600 req/min per user |
| Admin bulk (block import, bulk recall) | 5 req/hour |
| Tenant portal reads | 120 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 Lookup | per-pod concurrency 500 |
| gRPC state-mutation | per-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. .protoversioned in repo; TypeScript + Go clients generated and published to internal package registry.- Pact contract tests verify
sms-orchestrator ↔ numbering-serviceandcustomer-portal ↔ numbering-serviceon every CI run.
End of API_CONTRACTS.md