CDR Mediation Service — API Contracts
Version: 1.0 Status: Draft Owner: Commerce + Regulator Liaison Last Updated: 2026-04-21 Companion: APPLICATION_LOGIC · SYNC_CONTRACT · SECURITY_MODEL · EVENT_SCHEMAS
The cdr-mediation-service exposes a single interface plane:
- HTTPS REST on
:3018— fronted by Kong, authenticated via platform JWT + RBAC, or via mTLS-only on internal network (forregulator-portal-service).
There is no gRPC hot path. CDR generation is event-driven (sms.dlr.inbound → CDR row), and the regulator-portal-service queries are read-heavy, low-volume (≤ 100 RPS), infrequent admin workflows. The absence of a gRPC plane is an intentional design choice to minimise the attack surface facing regulator-portal-service.
1. Base path and versioning
All endpoints are under /v1/cdr. Per-export-schema variants are carried in request bodies, not URLs, so that the regulator can evolve the TAP/RAP schema without breaking the REST API. Breaking REST changes bump the path to /v2/cdr/* with a 90-day deprecation window (see SYNC_CONTRACT §5).
2. Authentication & authorization
| Caller | Authentication | Required scope |
|---|---|---|
admin-dashboard (platform operator) | Platform JWT (RS256, JWKS from auth-service) | cdr:read, cdr:write, or cdr:admin |
regulator-portal-service | mTLS (CN=regulator-portal-service) + regulator JWT | cdr:read (regulator-scoped) or cdr:verify |
billing-service (internal recon) | mTLS only | cdr:read |
analytics-service (cold-tier export) | mTLS only | cdr:read |
| Operator self-service (future) | Platform JWT with tenant scope | cdr:read RLS-gated |
Roles are enforced by NestJS RoleGuard at handler boundary + Postgres RLS on tenant-scoped queries. Bulk mutation endpoints require a secondary X-Approver-Jwt header (four-eyes principle) when impact exceeds thresholds declared below.
3. Endpoint catalog
3.1 Query / read
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/cdr/records | cdr:read | List CDRs with cursor pagination + filters |
| GET | /v1/cdr/records/{cdrId} | cdr:read | Single CDR with row hash + chain hash |
| GET | /v1/cdr/rollups | cdr:read | List hourly rollups with filters |
| GET | /v1/cdr/rollups/{bucketHour}/{operatorId} | cdr:read | Single rollup with bucketRoot + chainHash |
| GET | /v1/cdr/exports | cdr:read | List TAP/RAP exports with delivery status |
| GET | /v1/cdr/exports/{exportId} | cdr:read | Single export with signature metadata |
| GET | /v1/cdr/exports/{exportId}/delivery-log | cdr:read | Full delivery attempt history |
| GET | /v1/cdr/adjustments | cdr:read | List adjustments with filters |
| GET | /v1/cdr/adjustments/jobs/{jobId} | cdr:read | Bulk re-rate job status |
| GET | /v1/cdr/quarantine | cdr:admin | List quarantined records |
| GET | /v1/cdr/transparency/{day} | public + cdr:read | Transparency log anchor + inclusion proof |
| GET | /v1/cdr/audit | cdr:admin | regulator-auditor | Chain verifier audit log (append-only) |
Filters (query params) for /records:
tenantId, operatorId, bucketHourFrom, bucketHourTo, messageId, msisdnHashTo, adjustmentType, state, cursor, limit (≤ 500, default 100).
Response envelope:
{
"items": [ { "cdrId": "cdr_01HV...", ... } ],
"nextCursor": "eyJidWNrZXRIb3VyIjoi...",
"total": 123456
}
3.2 Chain & integrity
| Method | Path | Role | Purpose |
|---|---|---|---|
| POST | /v1/cdr/verify | cdr:read | Verify a single CDR's row hash |
| POST | /v1/cdr/chain/verify | cdr:verify | Verify a bucket-hour chain; optional Merkle inclusion proof |
| POST | /v1/cdr/chain/verify/range | cdr:verify | Walk a 24 h range; returns aggregate verification summary |
Request — single CDR verify:
POST /v1/cdr/verify
{ "cdrId": "cdr_01HV2P..." }
Response 200:
{
"cdrId": "cdr_01HV2P...",
"bucketHour": "2026-04-20T13:00:00Z",
"storedRowHash": "3c7b...",
"computedRowHash": "3c7b...",
"chainHashPrev": "b11a...",
"status": "VALID" | "MISMATCH"
}
Request — bucket chain verify:
POST /v1/cdr/chain/verify
{
"bucketHour": "2026-04-20T13:00:00Z",
"operatorId": "AWCC",
"proofForCdrId": "cdr_01HV2P..."
}
Response 200:
{
"bucketRoot": "9f3c...",
"chainHash": "73a8...",
"prevChainHash": "1200...",
"recordCount": 8421,
"sealedAt": "2026-04-20T13:05:12Z",
"signerKeyId": "cdr-export-signer-v1",
"verified": true,
"inclusionProof": {
"leafIndex": 3124,
"siblings": ["...", "...", ...]
}
}
3.3 Adjustments
| Method | Path | Role | Purpose |
|---|---|---|---|
| POST | /v1/cdr/{cdrId}/adjustments | cdr:write | Issue CORRECTION or VOID |
| POST | /v1/cdr/adjustments/rerate | cdr:write (+ X-Approver-Jwt > 100k) | Bulk RE_RATE |
| GET | /v1/cdr/{cdrId}/adjustments | cdr:read | List adjustments for a CDR (chain) |
Create body — CORRECTION:
POST /v1/cdr/cdr_01HV2P.../adjustments
Idempotency-Key: op-1234
{
"type": "CORRECTION",
"correctedFields": {
"tapTariffClass": "A042",
"chargeAmount": "0.038400"
},
"reason": "Tariff misclassification — transactional SMS to MTN_AF",
"ticketId": "JIRA-CDR-4821"
}
Response 201:
{
"adjustmentId": "adj_01HV9T...",
"originalCdrId": "cdr_01HV2P...",
"bucketHour": "2026-04-21T09:00:00Z",
"rapBatchExpected": "2026-04-22T22:30:00Z"
}
Create body — VOID:
{
"type": "VOID",
"voidReason": "DUPLICATE_DLR",
"reason": "DLR redelivered after consumer crash; duplicate projection confirmed by trace XYZ",
"ticketId": "JIRA-CDR-4822"
}
Create body — bulk RE_RATE:
POST /v1/cdr/adjustments/rerate
X-Approver-Jwt: eyJhbGciOi...
{
"filter": {
"tenantId": "t_01HA...",
"operatorId": "AWCC",
"bucketHourFrom": "2026-04-01T00:00:00Z",
"bucketHourTo": "2026-04-14T23:59:59Z"
},
"newPricingTableId": "price_01HW...",
"ticketId": "JIRA-CDR-5000",
"reason": "Retroactive transactional tariff change per contract addendum"
}
Response 202:
{
"jobId": "adjjob_01HX...",
"estimatedImpact": 84200,
"requiresApproval": false
}
Impact > 100,000 CDRs: 403 FOUR_EYES_REQUIRED if X-Approver-Jwt missing or approver identity equals requester.
3.4 Export operations
| Method | Path | Role | Purpose |
|---|---|---|---|
| POST | /v1/cdr/exports/{exportId}/redrop | cdr:admin | Re-deliver signed file without re-signing |
| POST | /v1/cdr/exports/trigger | cdr:admin | Force a TAP/RAP encoder run outside schedule |
| POST | /v1/cdr/quarantine/{quarantineId}/resubmit | cdr:admin | Re-encode after fix |
| POST | /v1/cdr/exports/{exportId}/mark-acked | regulator mTLS only | ATRA-callable ack endpoint |
Trigger body:
POST /v1/cdr/exports/trigger
{
"exportType": "TAP_3_12" | "RAP_1_5",
"settlementDay": "2026-04-19",
"operatorId": "AWCC",
"schemaVariant": "atra-tap-v2",
"reason": "Regulator urgent request — missing file retransmit"
}
3.5 Schema adapter admin
| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | /v1/cdr/regulator-schemas | cdr:admin | List available schema variants |
| POST | /v1/cdr/regulator-schemas/{variant}:activate | cdr:admin + four-eyes | Activate a new schema variant |
| GET | /v1/cdr/regulator-schemas/{variant}/validate | cdr:admin | Dry-run encode 100 sample CDRs |
3.6 Health & introspection
| Method | Path | Caller | Purpose |
|---|---|---|---|
| GET | /health/live | K8s | Liveness |
| GET | /health/ready | K8s | Readiness (PG + NATS + Vault reachable) |
| GET | /metrics | Prometheus | Scrape |
| GET | /v1/cdr/openapi.json | Any authenticated | OpenAPI 3.1 generated doc |
4. Error envelope
{
"error": {
"code": "CDR_BUCKET_NOT_SEALED",
"message": "Bucket 2026-04-21T09:00:00Z not yet sealed",
"details": {
"bucketHour": "2026-04-21T09:00:00Z",
"operatorId": "AWCC",
"expectedSealBy": "2026-04-21T09:05:00Z"
},
"traceId": "00-abc-..."
}
}
| HTTP | Code | When |
|---|---|---|
| 400 | CDR_VALIDATION_FAILED | Body / query param invalid |
| 401 | UNAUTHENTICATED | Missing/invalid JWT or mTLS |
| 403 | INSUFFICIENT_SCOPE | Role lacks required scope |
| 403 | FOUR_EYES_REQUIRED | Bulk re-rate needs approver |
| 404 | CDR_NOT_FOUND / EXPORT_NOT_FOUND / ADJUSTMENT_NOT_FOUND | — |
| 409 | BUCKET_NOT_SEALED | Chain verify before seal |
| 409 | ALREADY_VOIDED | Voiding a voided CDR |
| 409 | DUPLICATE_FILE | Sequence collision |
| 409 | EXPORT_ALREADY_ACKED | Redrop after ACK |
| 422 | CDR_CHAIN_MISMATCH | Recomputation does not match stored hash (rare; usually 500 + SEV1) |
| 422 | REGULATOR_SCHEMA_INVALID | Variant cannot encode provided row set |
| 429 | RATE_LIMITED | Kong rate limit |
| 500 | INTERNAL | Unhandled |
| 503 | DEPENDENCY_UNAVAILABLE | Postgres / Vault / HSM / NATS down |
| 503 | HSM_UNAVAILABLE | Signing pipeline degraded |
Error codes follow the platform convention in docs/standards/ERROR_CODES.md.
5. Pagination
Cursor-based for all list endpoints. Opaque base64 cursor encodes the last (bucketHour, cdrSequence) tuple. limit ≤ 500 (soft cap 100). nextCursor absent when last page reached. No offset support (unsafe at scale).
6. Rate limits (Kong)
| Surface | Limit |
|---|---|
Read endpoints (GET) | 600 req/min per operator |
Write endpoints (POST single adjustment) | 120 req/min per operator |
| Bulk re-rate | 5 req/hour per operator |
| Chain verify | 60 req/min per regulator tenant |
| Quarantine resubmit | 60 req/min |
7. Versioning & deprecation
/v1/cdr/*— additive changes non-breaking (new optional fields, new endpoints).- Breaking changes →
/v2/cdr/*, co-hosted for 90 days, deprecation headerDeprecation: true; sunset="<rfc3339>"on/v1. - The
schemaVariantparameter in request bodies (for exports) is independent of the REST API version — ATRA schema revisions do not require a REST bump.
8. OpenAPI + proto artefacts
- OpenAPI 3.1 served at
GET /v1/cdr/openapi.json. - Schema-validation tests (
spectral) run on every CI build. - No
.protoartefact (service does not expose gRPC). - TAP 3.12 ASN.1 modules shipped under
config/tap-schemas/atra-tap-v1.asn1etc.; loaded at runtime by the encoder.
9. Cross-References
- APPLICATION_LOGIC.md — use case implementations
- EVENT_SCHEMAS.md — NATS events fired alongside REST mutations
- SECURITY_MODEL.md — JWT scopes, mTLS, four-eyes approval
- regulator-portal-service/API_CONTRACTS.md — downstream consumer
docs/standards/ERROR_CODES.md
End of API_CONTRACTS.md