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:
- gRPC on
:50071(mTLS required) — invoked bycompliance-engine,routing-engine,sms-firewall-service, and tenant SDKs. Hot path forCheckConsent(P95 ≤ 5 ms). - HTTPS REST on
:3071— fronted by Kong; JWT + RBAC. Tenant API forRecordConsent/RevokeConsent/bulk-import; admin API for DND admin and audit query; citizen-facing for self-service. - 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 status | Condition | Caller behaviour |
|---|---|---|
OK | Verdict / write succeeded | Act on response |
INVALID_ARGUMENT | Bad MSISDN, unknown scope, malformed tenant_id, missing required field | Surface as 400 to tenant; never retry |
PERMISSION_DENIED | Caller's tenant_id claim does not match request tenant_id | Audit on caller side; never retry |
FAILED_PRECONDITION | Tenant suspended; double-opt-in not confirmed | Tenant must address state and retry |
ABORTED | Idempotency-key conflict (different payload, same key) | Caller fixes payload or key |
RESOURCE_EXHAUSTED | Per-pod concurrency cap (default 2,000 in-flight CheckConsent) | Backoff + retry |
UNAVAILABLE | Postgres or Redis unreachable | compliance-engine treats as fail-closed BLOCK; retry by NATS redelivery |
INTERNAL | Unhandled exception (logged; not exposed) | Retry once with backoff |
DEADLINE_EXCEEDED | > 1 s default deadline | Retry; 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).
2.1 Tenant — consent records
| Method | Path | Role / Scope | Purpose |
|---|---|---|---|
| GET | /v1/consent/records/{msisdn} | Tenant consent:read | Current record(s) for a MSISDN within tenant scope (RLS-gated) |
| POST | /v1/consent/records | Tenant consent:write | Record opt-in (CONS-US-002) |
| DELETE | /v1/consent/records/{msisdn}?scope= | Tenant consent:write | Revoke (CONS-US-017) |
| POST | /v1/consent/records:bulk-import | Tenant consent:write | CSV 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
| Method | Path | Role | Purpose |
|---|---|---|---|
| POST | /v1/consent/double-opt-in/initiate | Tenant consent:write | Start 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:read | Status check |
2.3 Tenant — false-positive feedback (CONS-US-011)
| Method | Path | Role | Purpose |
|---|---|---|---|
| POST | /v1/consent/feedback/false-positive | Tenant consent:write | Body: { msisdn, mo, recordedTime, justification }. Creates a triage ticket; payload encrypted at rest. |
2.4 Citizen self-service
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /v1/consent/citizen/otp/request | Public, captcha-gated | Request OTP for MSISDN verification |
| POST | /v1/consent/citizen/otp/verify | Public + OTP | Returns short-lived citizen-jwt scoped to the verified MSISDN |
| GET | /v1/consent/citizen/records | Citizen JWT | List of all tenants holding consent for the verified MSISDN (CONS-US-006) |
| POST | /v1/consent/citizen/revoke | Citizen JWT | Body: { tenantId, scope } or { all: true } for STOP-ALL |
| POST | /v1/consent/citizen/erasure | Citizen JWT | Initiate GDPR erasure (CONS-US-015) |
2.5 Admin — DND registry
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/consent/dnd | platform.consent.admin, platform.regulator | Paginated list of National DND entries |
| GET | /v1/admin/consent/dnd/{msisdn} | platform.consent.admin, platform.regulator | Single MSISDN status |
| POST | /v1/admin/consent/dnd/resync | platform.consent.admin | Force ATRA pull (CONS-US-001 §5) |
| GET | /v1/admin/consent/dnd/sync-runs | platform.consent.admin | Recent sync history |
2.6 Admin — STOP-keyword catalog (CONS-US-009)
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/consent/stop-keywords?language= | platform.consent.admin | List |
| POST | /v1/admin/consent/stop-keywords | platform.consent.admin | Add tenant or platform variant |
| DELETE | /v1/admin/consent/stop-keywords/{keywordId} | platform.consent.admin | Soft-delete (rejected if isPlatformDefault) |
| POST | /v1/admin/consent/stop-keywords/reload | platform.consent.admin | Force in-process catalog reload |
2.7 Admin — policy
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/consent/policy | platform.consent.admin | Current stop_scope, retention, etc. |
| PUT | /v1/admin/consent/policy | platform.consent.admin (dual-control) | Update; requires second-approver header X-Approver-User-Id |
2.8 Admin — audit (regulator interface)
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/consent/audit | platform.regulator, platform.consent.admin | Query 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.admin | Hash-chain integrity report (CONS-US-012 §3) |
| POST | /v1/admin/consent/audit/restore | platform.regulator | Body: { from, to, msisdn? } — submits S3 cold-archive restore job; returns jobId; SLA ≤ 1 h |
| GET | /v1/admin/consent/audit/restore/{jobId} | platform.regulator | Restore job status + signed download URL when ready |
2.9 Admin — erasure management
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/admin/consent/erasure-requests | platform.consent.admin, platform.regulator | Pending and recent erasure requests |
| POST | /v1/admin/consent/erasure-requests/{erasureId}/process | platform.consent.admin | Force-run processor for one request |
2.10 Internal / operational
| Method | Path | Caller | Purpose |
|---|---|---|---|
| GET | /health/live | Kubernetes | Liveness |
| GET | /health/ready | Kubernetes | Readiness — verifies Redis ping, Postgres SELECT 1, NATS connection |
| GET | /metrics | Prometheus | Scrape |
| GET | /v1/consent/openapi.json | Any authenticated caller | OpenAPI 3.1 |
| GET | /v1/consent/proto/consent.proto | Any authenticated caller | gRPC 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"
}
}
| HTTP | Code | When |
|---|---|---|
| 400 | CONSENT_VALIDATION_FAILED | Bad payload (MSISDN, scope, source) |
| 401 | UNAUTHENTICATED | Missing/invalid JWT |
| 403 | INSUFFICIENT_SCOPE | Caller lacks consent:write / RBAC role |
| 404 | NOT_FOUND | Record/erasure/optin not found |
| 409 | CONFLICT | Idempotency-key conflict; duplicate revoke; double-opt-in already exists |
| 410 | GONE | Erasure already completed for this MSISDN |
| 422 | BUSINESS_RULE_VIOLATION | Trying to remove a platform-default keyword; trying to record consent for a SUSPENDED tenant |
| 429 | RATE_LIMITED | Kong rate-limit; citizen OTP throttling |
| 451 | NATIONAL_DND_LOCK | Trying to record consent for an MSISDN in National DND with category = FULL_BLOCK |
| 500 | INTERNAL | Unhandled internal error |
| 503 | DEPENDENCY_UNAVAILABLE | Postgres / Redis / NATS unreachable; ATRA endpoint unreachable on dnd/resync |
5. Versioning
- gRPC:
ghasi.sms.consent.v1. Breaking change →v2parallel for ≥ 90 days. - REST:
/v1/consent/*. Additive non-breaking; breaking →/v2/consent/*. - Event subjects:
consent.<topic>.v1(perdocs/standards/EVENT_NAMING_AND_VERSIONING.md).
6. Rate limits & quotas (Kong)
| Surface | Limit |
|---|---|
Tenant RecordConsent REST | 600 req/min per tenant; 50,000 req/day per tenant |
Tenant bulk-import | 5 imports/hour per tenant; max 100,000 rows per file |
Tenant SDK gRPC RecordConsent | 2,000 req/min per tenant cert |
Tenant SDK gRPC RecordConsentBatch | 60 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 request | 5 per MSISDN per hour |
Citizen OTP verify | 5 per MSISDN per hour |
Citizen revoke | 30 per citizen-jwt per hour |
Citizen erasure | 1 active erasure per MSISDN |
Admin audit query | 60 req/min per user |
Admin audit/restore | 5 req/hour per user |
Public double-opt-in/confirm | 60 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.jsonand published to the internal API registry on each deploy. .protofiles versioned in this repo; generated TypeScript, Go, and Python clients published to the internal package registry.- Contract tests (Pact) verify
compliance-engine ↔ consent-ledger gRPCandtenant-portal ↔ consent-ledger RESTon every CI run (see TESTING_STRATEGY §4).