Skip to main content

Consent Ledger Service — API Contracts

Version: 1.0 Status: Draft Owner: Trust & Safety Last Updated: 2026-04-21 Companion: SYNC_CONTRACT · APPLICATION_LOGIC · SECURITY_MODEL

consent-ledger-service exposes three interface planes:

  1. gRPC on :50071 (mTLS required) — invoked by compliance-engine, routing-engine, sms-firewall-service, and tenant SDKs. Hot path for CheckConsent (P95 ≤ 5 ms).
  2. HTTPS REST on :3071 — fronted by Kong; JWT + RBAC. Tenant API for RecordConsent/RevokeConsent/bulk-import; admin API for DND admin and audit query; citizen-facing for self-service.
  3. NATS JetStream consumer (sms.mo.inbound) for STOP-keyword processing. See EVENT_SCHEMAS.

1. gRPC service — ConsentLedgerService.v1

Full proto in SYNC_CONTRACT §3. Reproduced here for reference.

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

service ConsentLedgerService {
// Hot path. P95 ≤ 5 ms (cache hit), P99 ≤ 20 ms (cache miss + PG read).
// Sustained 5,000 RPS per replica. mTLS required.
rpc CheckConsent (CheckConsentRequest) returns (CheckConsentResponse);

// Tenant-scoped writes. P95 ≤ 80 ms.
rpc RecordConsent (RecordConsentRequest) returns (RecordConsentResponse);
rpc RecordConsentBatch (RecordConsentBatchRequest) returns (RecordConsentBatchResponse);
rpc RevokeConsent (RevokeConsentRequest) returns (RevokeConsentResponse);

// Reputation lookup — optional advisory metadata for risk-scoring callers.
rpc GetReputation (GetReputationRequest) returns (GetReputationResponse);
}

message CheckConsentRequest {
string tenant_id = 1; // UUIDv4
string msisdn = 2; // E.164
ConsentScope scope = 3; // default TRANSACTIONAL when UNSPECIFIED
string trace_id = 4;
Lane lane = 5; // optional — only P0_EMERGENCY changes behaviour
}

message CheckConsentResponse {
bool allowed = 1;
CheckConsentReason reason = 2;
string record_id = 3; // populated when reason = ALLOWED_TENANT_RECORD or BLOCKED_OPT_OUT
google.protobuf.Timestamp cached_at = 4;
google.protobuf.Timestamp valid_until = 5;
}

message RecordConsentRequest {
string tenant_id = 1;
string msisdn = 2;
ConsentScope scope = 3;
ConsentSource source = 4;
VerificationMethod method = 5;
google.protobuf.Timestamp valid_until = 6;
string idempotency_key = 7;
}

message RecordConsentResponse {
string record_id = 1;
google.protobuf.Timestamp created_at = 2;
}

message RecordConsentBatchRequest {
string tenant_id = 1;
repeated RecordConsentRequest entries = 2; // max 1000
string batch_id = 3; // tenant-supplied for idempotency
}

message RecordConsentBatchResponse {
int32 accepted = 1;
int32 rejected = 2;
repeated BatchRowError errors = 3;
}

message RevokeConsentRequest {
string tenant_id = 1;
string msisdn = 2;
ConsentScope scope = 3; // SCOPE_ALL revokes every scope for this tenant
RevokedReason reason = 4;
string idempotency_key = 5;
}

message RevokeConsentResponse {
string record_id = 1;
google.protobuf.Timestamp revoked_at = 2;
}

message GetReputationRequest {
string msisdn = 1;
}

message GetReputationResponse {
bool national_dnd = 1;
int32 stop_mo_events_30d = 2;
int32 distinct_tenants_with_optin = 3;
google.protobuf.Timestamp last_stop_at = 4;
}

enum ConsentScope {
SCOPE_UNSPECIFIED = 0;
TRANSACTIONAL = 1;
MARKETING = 2;
OTP = 3;
EMERGENCY = 4;
SCOPE_ALL = 99; // revoke-only
}

enum CheckConsentReason {
REASON_UNSPECIFIED = 0;
ALLOWED_TENANT_RECORD = 1;
ALLOWED_DEFAULT_TRANSACTIONAL = 2;
BLOCKED_NO_RECORD = 3;
BLOCKED_OPT_OUT = 4;
BLOCKED_EXPIRED = 5;
BLOCKED_NATIONAL_DND = 6;
CONSENT_UNKNOWN = 7;
}

enum VerificationMethod {
METHOD_UNSPECIFIED = 0;
DOUBLE_OPT_IN = 1;
KYC_AT_PURCHASE = 2;
WET_SIGNATURE_SCAN = 3;
BULK_IMPORT_ATTESTATION = 4;
TENANT_API = 5;
CITIZEN_PORTAL = 6;
STOP_MO = 7;
}

enum RevokedReason {
REVOKED_UNSPECIFIED = 0;
STOP_KEYWORD = 1;
CITIZEN_PORTAL = 2;
TENANT_API = 3;
DOUBLE_OPT_IN_EXPIRED = 4;
ERASURE_REQUEST = 5;
NATIONAL_DND_OVERRIDE = 6;
}

enum Lane {
LANE_UNSPECIFIED = 0;
P0_EMERGENCY = 1;
P1_OTP = 2;
P2_TRANSACTIONAL = 3;
P3_MARKETING = 4;
}

gRPC error mapping

gRPC statusConditionCaller behaviour
OKVerdict / write succeededAct on response
INVALID_ARGUMENTBad MSISDN, unknown scope, malformed tenant_id, missing required fieldSurface as 400 to tenant; never retry
PERMISSION_DENIEDCaller's tenant_id claim does not match request tenant_idAudit on caller side; never retry
FAILED_PRECONDITIONTenant suspended; double-opt-in not confirmedTenant must address state and retry
ABORTEDIdempotency-key conflict (different payload, same key)Caller fixes payload or key
RESOURCE_EXHAUSTEDPer-pod concurrency cap (default 2,000 in-flight CheckConsent)Backoff + retry
UNAVAILABLEPostgres or Redis unreachablecompliance-engine treats as fail-closed BLOCK; retry by NATS redelivery
INTERNALUnhandled exception (logged; not exposed)Retry once with backoff
DEADLINE_EXCEEDED> 1 s default deadlineRetry; investigate latency

Streaming variants (future, not in v1)

  • WatchConsentChanges (server-streaming) — for tenant SIEM integration to receive consent change events without subscribing to NATS directly.
  • BulkCheckConsent — for batched pre-flight checks (deferred; bulk users currently call individually with HTTP/2 multiplexing which is sufficient at observed RPS).

2. REST — public surface

Base paths:

  • Tenant: /v1/consent/...
  • Admin: /v1/admin/consent/...
  • Citizen self-service: /v1/consent/citizen/...

All endpoints fronted by Kong with jwt plugin + rate-limiting-advanced. State-changing endpoints accept Idempotency-Key headers (24 h replay window).

MethodPathRole / ScopePurpose
GET/v1/consent/records/{msisdn}Tenant consent:readCurrent record(s) for a MSISDN within tenant scope (RLS-gated)
POST/v1/consent/recordsTenant consent:writeRecord opt-in (CONS-US-002)
DELETE/v1/consent/records/{msisdn}?scope=Tenant consent:writeRevoke (CONS-US-017)
POST/v1/consent/records:bulk-importTenant consent:writeCSV bulk import (CONS-US-018)

POST /v1/consent/records request body:

{
"msisdn": "+93701234567",
"scope": "MARKETING",
"verificationMethod": "WEB_FORM",
"validUntil": "2027-04-21T00:00:00Z",
"source": {
"type": "WEB_FORM",
"ref": "campaign-spring-2026",
"capturedAt": "2026-04-21T10:14:22Z",
"capturedIp": "203.0.113.42",
"capturedUserAgent": "Mozilla/5.0 ..."
}
}

Response 201 Created:

{
"recordId": "cn_01HZX7ABCD23YJ",
"createdAt": "2026-04-21T10:14:22.812Z",
"links": { "self": "/v1/consent/records/+93701234567?scope=MARKETING" }
}

2.2 Tenant — double-opt-in

MethodPathRolePurpose
POST/v1/consent/double-opt-in/initiateTenant consent:writeStart opt-in flow; sends SMS via channel-router
GET/v1/consent/double-opt-in/confirm?token=Public (rate-limited)Citizen redemption endpoint
GET/v1/consent/double-opt-in/{optinId}Tenant consent:readStatus check

2.3 Tenant — false-positive feedback (CONS-US-011)

MethodPathRolePurpose
POST/v1/consent/feedback/false-positiveTenant consent:writeBody: { msisdn, mo, recordedTime, justification }. Creates a triage ticket; payload encrypted at rest.

2.4 Citizen self-service

MethodPathAuthPurpose
POST/v1/consent/citizen/otp/requestPublic, captcha-gatedRequest OTP for MSISDN verification
POST/v1/consent/citizen/otp/verifyPublic + OTPReturns short-lived citizen-jwt scoped to the verified MSISDN
GET/v1/consent/citizen/recordsCitizen JWTList of all tenants holding consent for the verified MSISDN (CONS-US-006)
POST/v1/consent/citizen/revokeCitizen JWTBody: { tenantId, scope } or { all: true } for STOP-ALL
POST/v1/consent/citizen/erasureCitizen JWTInitiate GDPR erasure (CONS-US-015)

2.5 Admin — DND registry

MethodPathRolePurpose
GET/v1/admin/consent/dndplatform.consent.admin, platform.regulatorPaginated list of National DND entries
GET/v1/admin/consent/dnd/{msisdn}platform.consent.admin, platform.regulatorSingle MSISDN status
POST/v1/admin/consent/dnd/resyncplatform.consent.adminForce ATRA pull (CONS-US-001 §5)
GET/v1/admin/consent/dnd/sync-runsplatform.consent.adminRecent sync history

2.6 Admin — STOP-keyword catalog (CONS-US-009)

MethodPathRolePurpose
GET/v1/admin/consent/stop-keywords?language=platform.consent.adminList
POST/v1/admin/consent/stop-keywordsplatform.consent.adminAdd tenant or platform variant
DELETE/v1/admin/consent/stop-keywords/{keywordId}platform.consent.adminSoft-delete (rejected if isPlatformDefault)
POST/v1/admin/consent/stop-keywords/reloadplatform.consent.adminForce in-process catalog reload

2.7 Admin — policy

MethodPathRolePurpose
GET/v1/admin/consent/policyplatform.consent.adminCurrent stop_scope, retention, etc.
PUT/v1/admin/consent/policyplatform.consent.admin (dual-control)Update; requires second-approver header X-Approver-User-Id

2.8 Admin — audit (regulator interface)

MethodPathRolePurpose
GET/v1/admin/consent/auditplatform.regulator, platform.consent.adminQuery by msisdn, tenantId, eventType, from, to. Cursor pagination ≤ 100/page (CONS-US-014)
GET/v1/admin/consent/audit/verify?from=&to=platform.regulator, platform.consent.adminHash-chain integrity report (CONS-US-012 §3)
POST/v1/admin/consent/audit/restoreplatform.regulatorBody: { from, to, msisdn? } — submits S3 cold-archive restore job; returns jobId; SLA ≤ 1 h
GET/v1/admin/consent/audit/restore/{jobId}platform.regulatorRestore job status + signed download URL when ready

2.9 Admin — erasure management

MethodPathRolePurpose
GET/v1/admin/consent/erasure-requestsplatform.consent.admin, platform.regulatorPending and recent erasure requests
POST/v1/admin/consent/erasure-requests/{erasureId}/processplatform.consent.adminForce-run processor for one request

2.10 Internal / operational

MethodPathCallerPurpose
GET/health/liveKubernetesLiveness
GET/health/readyKubernetesReadiness — verifies Redis ping, Postgres SELECT 1, NATS connection
GET/metricsPrometheusScrape
GET/v1/consent/openapi.jsonAny authenticated callerOpenAPI 3.1
GET/v1/consent/proto/consent.protoAny authenticated callergRPC proto

3. Pagination

All list endpoints use cursor-based pagination per platform standard. Response envelope:

{
"items": [ /* … */ ],
"nextCursor": "eyJzZXEiOjEyM30=",
"previousCursor": null,
"total": 1234
}

nextCursor is opaque (base64-encoded { seq, sortKey }). total is best-effort and may be omitted on partitioned audit queries.


4. Error shape (REST)

Uniform envelope (per docs/standards/ERROR_CODES.md):

{
"error": {
"code": "CONSENT_VALIDATION_FAILED",
"message": "MSISDN must be E.164",
"details": { "field": "msisdn", "value": "0701234567" },
"traceId": "00-abc-def-01"
}
}
HTTPCodeWhen
400CONSENT_VALIDATION_FAILEDBad payload (MSISDN, scope, source)
401UNAUTHENTICATEDMissing/invalid JWT
403INSUFFICIENT_SCOPECaller lacks consent:write / RBAC role
404NOT_FOUNDRecord/erasure/optin not found
409CONFLICTIdempotency-key conflict; duplicate revoke; double-opt-in already exists
410GONEErasure already completed for this MSISDN
422BUSINESS_RULE_VIOLATIONTrying to remove a platform-default keyword; trying to record consent for a SUSPENDED tenant
429RATE_LIMITEDKong rate-limit; citizen OTP throttling
451NATIONAL_DND_LOCKTrying to record consent for an MSISDN in National DND with category = FULL_BLOCK
500INTERNALUnhandled internal error
503DEPENDENCY_UNAVAILABLEPostgres / Redis / NATS unreachable; ATRA endpoint unreachable on dnd/resync

5. Versioning

  • gRPC: ghasi.sms.consent.v1. Breaking change → v2 parallel for ≥ 90 days.
  • REST: /v1/consent/*. Additive non-breaking; breaking → /v2/consent/*.
  • Event subjects: consent.<topic>.v1 (per docs/standards/EVENT_NAMING_AND_VERSIONING.md).

6. Rate limits & quotas (Kong)

SurfaceLimit
Tenant RecordConsent REST600 req/min per tenant; 50,000 req/day per tenant
Tenant bulk-import5 imports/hour per tenant; max 100,000 rows per file
Tenant SDK gRPC RecordConsent2,000 req/min per tenant cert
Tenant SDK gRPC RecordConsentBatch60 req/min per tenant cert; max 1,000 entries per call
CheckConsent (gRPC, internal services)No Kong limit; per-pod concurrency 2,000 in-flight
Citizen OTP request5 per MSISDN per hour
Citizen OTP verify5 per MSISDN per hour
Citizen revoke30 per citizen-jwt per hour
Citizen erasure1 active erasure per MSISDN
Admin audit query60 req/min per user
Admin audit/restore5 req/hour per user
Public double-opt-in/confirm60 req/min per source IP

7. Idempotency

State-changing endpoints accept Idempotency-Key: <uuid-or-string> (max 64 chars). The service stores (tenantId, key, requestHash, response) for 24 h:

  • Same key + same hash → return cached response.
  • Same key + different hash → 409 CONFLICT { code: "IDEMPOTENCY_KEY_CONFLICT" }.

Bulk import uses batch_id instead.


8. OpenAPI / Proto artefacts

  • Generated OpenAPI 3.1 served at GET /v1/consent/openapi.json and published to the internal API registry on each deploy.
  • .proto files versioned in this repo; generated TypeScript, Go, and Python clients published to the internal package registry.
  • Contract tests (Pact) verify compliance-engine ↔ consent-ledger gRPC and tenant-portal ↔ consent-ledger REST on every CI run (see TESTING_STRATEGY §4).