Story NOTIF-001b: SMS provider adapter + @Async submit + status webhook + delivery_attempt lifecycle¶
Module: notifications Slice: NOTIF-001 sub-slice b (of NOTIF-001 in architecture §11) Side: [BACKEND] Version target: V0.0.0 Sub-version: v0.0.0 (architecture-backed) Priority: 3 Depends on: NOTIF-001a, NOTIF-001d (country-profile resolver wiring), AUDIT-002 (catalog extension) Can develop concurrently with: None — depends on NOTIF-001a and NOTIF-001d Merge order: Before NOTIF-002, NOTIF-003, NOTIF-004 Estimated complexity: M PRD User Stories: NOTIF-US-01 (AC-01.1 completion); NOTIF-US-06 partial (webhook signature + idempotency); NOTIF-US-09 (AC-09.1, AC-09.2, AC-09.4, AC-09.5) Wireframe: N/A — backend only
Objective¶
Complete the SMS happy path: after POST /dispatch commits, submit the message to the SMS provider via the SmsProvider adapter strategy (LetsTalk or Hub2 per OQ-NOTIF-01 — concrete pick lands before dev-start). Handle the status webhook (signature-verified, idempotent). Drive the notification through PENDING → DISPATCHING_SMS → SMS_SENT → AWAITING_CASCADE (or FAILED). Emit NOTIF_SENT / NOTIF_FAILURE audit events. Set wa_scheduled_for = sms_sent_at + 5 days to arm the v0.0.0 WhatsApp cascade (which actually fires in NOTIF-003).
Backend Scope¶
Entities¶
No new tables — extends behavior on notification and delivery_attempt from NOTIF-001a.
Migrations¶
None new. (No DDL changes; this story uses the columns already created in NOTIF-001a.)
Service Layer¶
SmsProviderinterface (named-interface, Spring Modulith):SmsSubmitResult send(SmsSubmitRequest req)— returns{provider_message_id, status:'SENT'|'HARD_ERROR', failure_class?}.- Per-call 10 s timeout (Resilience4j config — architecture §8).
- Implements idempotency via a client-ref (
delivery_attempt.attempt_idcarried as the provider's idempotency key — both LetsTalk and Meta support a client-ref per architecture §5 row 2). LetsTalkSmsAdapter implements SmsProvider(ORHub2SmsAdapter— concrete choice pending OQ-NOTIF-01; interface is fixed). Configured via env vars per §14.1:NOTIF_SMS_PROVIDER_BASE_URL,NOTIF_SMS_PROVIDER_API_KEY.SmsDispatcher—@Async, runs after-commit (@TransactionalEventListener(phase=AFTER_COMMIT)on theNotificationEnqueuedSpring event published from NOTIF-001a's transaction):- CAS-update
notification.status = 'DISPATCHING_SMS'(compare-and-swap fromPENDING). - Call
SmsProvider.send(...)withclient_ref = attempt_id. - On 2xx: update
delivery_attempt(status='SENT', provider_message_id=?, submitted_at=now()); updatenotification(status='SMS_SENT', sms_sent_at=now(), wa_scheduled_for = sms_sent_at + INTERVAL '5 days'); transition toAWAITING_CASCADEin the same TX (architecture §2.3 — after-commit). EmitNOTIF_SENToutbox event +notification.sentSpring application event. - On hard-error / network timeout / non-2xx: update
delivery_attempt(status='HARD_ERROR'); updatenotification(status='FAILED', last_failure_class=?). EmitNOTIF_FAILUREoutbox event +notification.failedSpring event. No retry in v0.0.0 (architecture §5 row 5 — v0.1.0 adds policy). StatusWebhookController—POST /api/v1/notifications/webhooks/{providerId}/status:- Verify HMAC signature on the raw body via the provider's adapter (each adapter exposes a
verifySignature(rawBody, signatureHeader)method). Reject 401 if invalid (logged as structured WARN — NOTIF_WEBHOOK_REJECTED audit lands at v0.1.0 per NOTIF-ADR-06; OQ-NOTIF-04). - Body ≤ 8 KB Content-Length cap (architecture §12 R3); Content-Type allow-list
application/json. - Resolve tenant via
provider_message_id→delivery_attempt.tenant_id(denormalized for this purpose — architecture §4.1). - Idempotency: dedup key
(provider_id, provider_message_id, status). If terminal status already applied, return 200 no-op. - On
DELIVERED: updatedelivery_attempt.status='DELIVERED', updatenotification.status='DELIVERED'IF currently inSMS_SENT(CAS — don't override anAWAITING_CASCADErow, see edge case below); append tostatus_timelineJSONB array. - On
FAILEDpost-submit: updatedelivery_attempt.status='FAILED', append to timeline; do not auto-flip notification — v0.0.0 has no webhook-loss reconciliation (architecture §5 row 4) and a late-FAILED webhook afterSMS_SENTis honest-scope acknowledged. - Webhook for unknown
provider_message_id: return 200 no-op (avoid provider retry storms — architecture §4.1). NotificationEnqueuedSpring application event — published from NOTIF-001adispatch()(this story adds the listener).notification.dispatched/notification.sent/notification.failedSpring application events — emitted (consumed by OP in V1; v0.0.0 fires silently per architecture §1.2 last row).
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
| POST | /api/v1/notifications/webhooks/{providerId}/status |
Provider envelope per §3.3 | 200 (idempotent) / 401 | Provider HMAC signature |
Validation Rules¶
- Webhook
Content-Length≤ 8192 bytes;Content-Type: application/json. Else 415 / 413. - Signature header REQUIRED — missing or invalid → 401 + WARN log.
provider_message_idREQUIRED in body.status∈{SENT, DELIVERED, FAILED}.
Multi-Tenant Considerations¶
- Webhook tenant resolution NEVER uses request body — always derived from
delivery_attempt.tenant_id(architecture §4.1). Webhooks for unknownprovider_message_idget 200 + drop, no cross-tenant data leak. SmsDispatcherruns withtenant_idin MDC; all DB queries scoped bytenant_id.- Both V1 DB profiles supported — NOTIF tables remain on
platformDataSource(NOTIF-ADR-01). correlation_idpropagated through MDC into the@Asynctask and into every outbox + Spring event (NOTIF-ADR-07).
Audit & Logging¶
NOTIF_SENT— emitted on provider 2xx with mandatory fields per audit-log §B.3:{tenant_id, agency_id, country_code, customer_id, contract_id, signed_link_id, correlation_id, channel:'SMS', provider_id, provider_message_id, attempt_index, result:'INFO'}. PII hashed viaPiiHasher.NOTIF_FAILURE— emitted on terminal SMS failure:{tenant_id, agency_id, country_code, customer_id, contract_id, correlation_id, last_channel:'SMS', failure_class, attempt_count:1, result:'FAILURE'}.- Webhook signature-rejection emits structured WARN log (NOT
NOTIF_WEBHOOK_REJECTEDaudit — that lands v0.1.0 per NOTIF-ADR-06). terminal_event_published_atset on first terminal Spring-event publication to guarantee exactly-once (architecture §2.1).
Frontend Scope¶
N/A — backend only.
Acceptance Criteria¶
AC-001b.1: Happy path — SMS submitted after commit, NOTIF_SENT emitted
Given a notification was enqueued in PENDING via NOTIF-001a
When the transaction commits and SmsDispatcher runs the @Async submit
And the SMS provider returns 2xx with a provider_message_id
Then `notification.status = 'AWAITING_CASCADE'`, `sms_sent_at` set, `wa_scheduled_for = sms_sent_at + 5 days`
And `delivery_attempt.status = 'SENT'`, `provider_message_id` populated
And outbox contains a `NOTIF_SENT` event with PII-hashed payload and the original `correlation_id`
And the Spring application event `notification.sent` is published exactly once
AC-001b.2: Status webhook is signature-verified + idempotent
Given a delivered SMS for `(provider_id='letstalk_sms_ci', provider_message_id='abc')`
When the provider POSTs `/webhooks/letstalk_sms_ci/status` with `{provider_message_id:'abc', status:'DELIVERED'}` and a valid HMAC header
Then the response is 200, `delivery_attempt.status='DELIVERED'`, `notification.status='DELIVERED'` (transitioned from SMS_SENT)
And the `status_timeline` JSONB has one new entry `{status:'DELIVERED', at:..., raw_response_truncated:<=8KB}`
When the same webhook payload is replayed (idempotency)
Then the response is 200 no-op, no duplicate timeline entry, `notification.status` unchanged
When a webhook arrives with a missing/invalid HMAC header
Then the response is 401, no DB write, a structured WARN log line is emitted
AC-001b.3: Provider hard-error → FAILED + NOTIF_FAILURE
Given a notification dispatched in NOTIF-001a
When SmsDispatcher calls the provider and receives a hard error (4xx) OR the 10s timeout fires with no 2xx
Then `notification.status = 'FAILED'`, `last_failure_class` populated (e.g. `INVALID_PHONE` / `SMS_HARD_ERROR` / `PROVIDER_TIMEOUT`)
And `delivery_attempt.status = 'HARD_ERROR'`
And outbox contains a `NOTIF_FAILURE` event with the correlation_id
And the Spring application event `notification.failed` is published exactly once
And no retry is attempted (v0.0.0 scope honest-acknowledged)
Compliance Rules¶
- ARTCI mitigation #4 (audit-log per send) —
NOTIF_SENT/NOTIF_FAILUREemission is non-negotiable. StatusVÉRIFIÉ. Critical-rule #3. - Proof of submission (CIMA Reg. 01-24 — proof of broker outreach) —
NOTIF_SENTis the proof of submission, NOT of delivery. Architecture §6.3 explicitly accepts this honest-scope limitation for v0.0.0. Webhook-loss reconciliation lands at V1 (NOTIF-US-06). - Webhook signature gate — FR-09 of the V1 PRD applies in spirit; structured WARN log instead of
NOTIF_WEBHOOK_REJECTEDaudit event in v0.0.0 (NOTIF-ADR-06 + OQ-NOTIF-04). - Law n°2013-450 (PII pseudonymization) —
PiiHasherapplied to phone + customer_first_name in every audit payload (audit-log AC-06). StatusVÉRIFIÉfor the mechanism.
Standards & Conventions¶
docs/standards/critical-rules.md(full)docs/standards/api-guidelines.md(webhook idempotency, error envelope)docs/standards/database-guidelines.md(JSONB append patterns, partial indexes)docs/standards/java-spring-guidelines.md(@TransactionalEventListener(AFTER_COMMIT),@Async, Resilience4j, MDC propagation across async tasks)docs/standards/multi-datasource-patterns.mddocs/standards/multi-tenant-model.mddocs/standards/compliance-discipline.mddocs/standards/tech-stack.md
Testing Requirements¶
Unit Tests¶
SmsDispatcherHappyPathTest— mocksSmsProvider, asserts state transitions, assertswa_scheduled_for = sms_sent_at + 5 days.SmsDispatcherHardErrorTest— provider returns 422 hard-error → FAILED + NOTIF_FAILURE.SmsDispatcherTimeoutTest— provider hangs > 10s → timeout → FAILED.StatusWebhookIdempotencyTest— replay of terminal status returns 200 no-op.StatusWebhookSignatureTest— invalid HMAC → 401, structured log emitted.LetsTalkSmsAdapterIdempotencyTest— repeatedsendwith sameclient_refreturns the sameprovider_message_id(WireMock stub asserts no double-submit).
Integration Tests¶
SmsSubmitE2ETest(WireMock) — fullPOST /dispatch→ WireMock receives the SMS submission → status webhook → notification ends inDELIVERED.WebhookTenantResolutionTest— webhook arrives, tenant correctly resolved viaprovider_message_idlookup; unknownprovider_message_idreturns 200 no-op without DB write.CorrelationIdPropagationTest—correlation_idfromPOST /dispatchappears inNOTIF_SENToutbox payload AND in@Asynctask log lines (MDC).AsyncMdcPropagationTest— confirms MDC values survive the@Asynctask boundary.
What QA Will Validate¶
- WireMock receipt count == dispatch count (no duplicate submits, no silent drops).
- p95
POST /dispatch→ SMS submit ≤ 5 s (architecture §8 end-to-end target). terminal_event_published_atset exactly once per notification — replay of a webhook does NOT re-publish.- Smoke test from architecture §14.2 ends with
NOTIF_SENTcount == dispatch count inaudit_log.
Out of Scope¶
- Inbound STOP webhook — NOTIF-002.
- Suppression pre-check at dispatch time — NOTIF-001c (this story still doesn't consult suppression; safe because NOTIF-001c lands before any real customer is reached in production).
- WhatsApp cascade firer — NOTIF-003 (this story only ARMS the cascade by setting
wa_scheduled_for; the firer that actually sends WA lives in NOTIF-003). - Crash recovery on
DISPATCHING_SMSrows — NOTIF-004. - Retry policy (transient errors) — v0.1.0.
NOTIF_WEBHOOK_REJECTEDaudit catalog entry — v0.1.0 (NOTIF-ADR-06).- Webhook-loss reconciliation polling — V1 (NOTIF-US-06).
- Operator timeline UI / manual retry — V1.
Definition of Done¶
- [ ]
SmsProviderinterface + at least one concrete adapter (LetsTalkSmsAdapterorHub2SmsAdapter— final pick per OQ-NOTIF-01) - [ ]
SmsDispatcherruns after-commit via@TransactionalEventListener(AFTER_COMMIT)+@Async - [ ]
POST /api/v1/notifications/webhooks/{providerId}/statusimplemented, HMAC-verified, idempotent - [ ] All 3 ACs pass against WireMock stubs (200/422/timeout scenarios)
- [ ] Unit + integration tests written and passing
- [ ] Tenant isolation verified on webhook resolution (unknown
provider_message_id→ 200 no-op, no DB write) - [ ]
correlation_idend-to-end propagation through MDC +@Async+ outbox payloads - [ ]
NOTIF_SENTandNOTIF_FAILUREaudit events present with correct mandatory fields + PII hashed - [ ]
terminal_event_published_atguards exactly-once Spring event publication - [ ]
wa_scheduled_forcorrectly computed assms_sent_at + INTERVAL '5 days'on the happy path - [ ] No silent failures on provider errors (every failure path produces a NOTIF_FAILURE audit event and
notification.failedSpring event) - [ ] Webhook signature-rejection produces a structured WARN log line (audit-eventification deferred to v0.1.0)
- [ ] p95
POST /dispatch→SMS_SENT≤ 5 s (architecture §8)