Skip to content

WhatsApp + SMS Application Readiness

Purpose

Builds the application-side production foundation for WhatsApp and SMS so provider activation later is configuration/data work, not architecture rework. Reuses the notification architecture introduced with SES (see Production Communications and Email Delivery Hardening) rather than introducing a second, unrelated messaging system - the claim/lease/ambiguous-outcome pattern, the INotificationChannel abstraction, and the config-gated-provider convention are all reused directly. No real WhatsApp/SMS provider is ever called anywhere in this codebase; both providers are disabled by default and, even when "enabled," the only registered IWhatsAppSender/ ISmsSender implementations are genuine no-ops (see "What is provider-ready" below).

An earlier, unmerged branch (commit f1f4f2a "feat: add WhatsApp consent and SMS OTP integration", reachable via feature/devops-automation/review/inventory-reservation) had already built a substantial WhatsApp/SMS/OTP implementation on a different, since-abandoned architecture (SendGrid-based email, a Notification/NotificationEnqueuer pipeline that predates this repository's current MarketplaceNotification/INotificationChannel design). That prior work was reviewed for design ideas (OTP hashing/expiry/attempt-limit shape, phone normalization edge cases) but deliberately not ported wholesale - doing so would itself have been "a second unrelated messaging system." Everything in this tranche is built fresh against this repository's current, SES-hardened architecture.

1. Channel model

MessageChannel (WhatsApp, Sms) and MessageClassification (Transactional, Otp, Marketing) are new enums. MessageDeliveryAttempt (new entity/table) is the WhatsApp/SMS delivery queue row - see "Shared delivery abstraction" below for why WhatsApp and SMS share one table/pipeline rather than each duplicating the claim/lease machinery independently.

Required concept Where it lives
Notification/event id MessageDeliveryAttempt.NotificationId (nullable - null for non-notification-driven sends like OTP)
Recipient phone MessageDeliveryAttempt.RecipientPhoneRaw
Normalized phone MessageDeliveryAttempt.RecipientPhoneNormalized (E.164)
Channel MessageDeliveryAttempt.Channel
Template key/version MessageDeliveryAttempt.TemplateKey / TemplateVersion
Locale MessageDeliveryAttempt.Locale
Transactional/OTP/marketing classification MessageDeliveryAttempt.Classification
Provider message id MessageDeliveryAttempt.ProviderMessageId
Delivery status MessageDeliveryAttempt.Status (MessageDeliveryStatus)
Attempt count MessageDeliveryAttempt.AttemptCount
Timestamps CreatedAt/UpdatedAt/LastAttemptAt/NextAttemptAt/ClaimedAt/LeaseExpiresAt
Failure code/reason MessageDeliveryAttempt.FailureCode / FailureReason
Correlation/idempotency key MessageDeliveryAttempt.CorrelationKey (unique-indexed - the real idempotency guard)

Both channels are off by default: Marketplace:Notifications:SmsEnabled (pre-existing key, previously a genuine no-op - now enqueues real rows) and Marketplace:Notifications:WhatsAppEnabled (new key).

2. Phone normalization

IPhoneNumberNormalizer (Application layer) is market-neutral in shape; IndianPhoneNumberNormalizer (Infrastructure) is the only implementation registered today. Accepts +91XXXXXXXXXX, 91XXXXXXXXXX, 0XXXXXXXXXX, and bare XXXXXXXXXX (the last digit-group always a 10-digit mobile number starting 6-9), tolerating spaces/hyphens/dots/parentheses as formatting noise. Anything else - wrong length, an 11-digit string that isn't a clear leading-zero form, letters, a misplaced +, a non-Indian country code - is rejected with an error, never guessed or silently rewritten. See IndianPhoneNumberNormalizer's own doc comment for the full rule set and PhoneNumberNormalizationTests.cs for the required scenarios (+91, leading zero, spaces/dashes, invalid lengths, malformed input).

A future non-India rollout adds a second IPhoneNumberNormalizer implementation (selected by market/locale) rather than changing this contract - the interface itself never assumed India.

3. Consent/preferences

NotificationConsent (new entity/table) is an append-only ledger - granting or withdrawing consent inserts a new row; "current state" for a (UserId, ConsentType) pair is its most recent row. This gives full audit history (consent type, granted/withdrawn, timestamp, source/channel, policy version, actor) without a separate event-log table.

ConsentType: TransactionalWhatsApp, TransactionalSms, MarketingWhatsApp, MarketingSms, OtpOrSecuritySms - Transactional and Marketing are tracked independently per channel deliberately (a customer may consent to transactional SMS but not transactional WhatsApp).

NotificationConsentGate.IsSendPermitted(classification, latestStatus) is the single, pure, stateless rule every consent check goes through:

Otp           -> always true (never gated - a user-initiated security action)
Marketing     -> latestStatus == Granted (fails closed - no record means no send)
Transactional -> latestStatus != Withdrawn (defaults to allowed; honors explicit withdrawal)

"Do not allow a generic marketing opt-in to override required transactional/security logic" is satisfied structurally: Marketing and Transactional/Otp are checked against different ConsentType values (NotificationConsentGate.ResolveConsentType), so granting MarketingWhatsApp has zero code-path effect on the Transactional/Otp branches.

4. India SMS/DLT metadata

SmsTemplate (new entity/table): TemplateKey (internal), DltEntityId, DltTemplateId, SenderHeaderId, TemplateCategory, RegisteredTemplateText (the exact DLT-registered text, {#var#} placeholders included verbatim), Locale, Classification, IsActive. No row is ever seeded with real, provider-approved content - see "What remains provider-specific" below. A future MSG91/Exotel/Gupshup/AWS SNS adapter maps TemplateKey to this row's DLT fields; no other code change is needed when a provider is selected.

5. WhatsApp template metadata

WhatsAppTemplate (new entity/table): TemplateKey (internal), MetaTemplateName, Locale, Category (Meta's own taxonomy - UTILITY/MARKETING/AUTHENTICATION, free text), Version, Status (WhatsAppTemplateStatus.Active/Inactive - this codebase's own local record of "safe to reference," not a check against Meta's real approval status, which requires a live WhatsApp Business Account connection this codebase does not have), ParameterPlaceholdersCsv (ordered placeholder names for the template's positional {{1}}, {{2}}, ... parameters). No row is ever seeded with a real, Meta-approved production template.

6. Delivery attempts

MessageDeliveryAttempt's claim/lease design mirrors EmailDeliveryAttempt exactly (see Email Delivery Hardening for the full reasoning, not repeated here): Pending/Retrying -> Processing (atomic conditional ExecuteUpdateAsync, one claimant) -> Accepted/Retrying/Failed/UnknownOutcome. Stale-lease sweep runs every tick from every replica. No provider network I/O happens inside a database transaction - MessageDeliveryBackgroundService (mirrors EmailDeliveryBackgroundService) dispatches both channels every tick, each with its own Channel-scoped claim/candidate query so a WhatsApp backlog never starves SMS or vice versa.

Shared delivery abstraction (duplication assessment)

The tranche brief asked whether a shared, generalized delivery abstraction could safely remove duplication across SES/WhatsApp/SMS. Assessed and deliberately scoped down: EmailDeliveryAttempt is a live, already-hardened, shipped pipeline - migrating it onto a new shared table/entity would be a real risk to a working system for a purely structural win, and the brief explicitly says "do not destabilize SES." Instead: WhatsApp and SMS (both brand new in this tranche) share one table/entity/repository/background-service dispatcher (MessageDeliveryAttempt/ IMessageDeliveryRepository/MessageDeliveryBackgroundService), with Channel as a discriminator column - this avoids writing the ~200 lines of claim/lease logic twice for two channels that are structurally identical, while leaving Email completely untouched. A future, carefully-planned migration of EmailDeliveryAttempt onto the same shared shape is possible but out of scope here.

7. Delivery receipts/webhooks foundation

MessageDeliveryReceipt (new entity/table) mirrors RazorpayWebhookEvent's idempotency pattern exactly: PayloadHash (SHA-256 of the raw payload, unique-indexed) is the real idempotency guard; the event is always recorded before any business-logic branch runs. MessageDeliveryEventType: Accepted, Sent, Delivered, Read, Failed, Rejected.

ProcessMessageDeliveryReceiptCommandHandler (Application layer) is the provider-neutral processing primitive:

  • Event idempotency: exact replay (identical raw payload) is a harmless no-op via PayloadHash uniqueness.
  • Out-of-order handling: a lifecycle rank (Accepted(0) < Sent(1) < Delivered(2) < Read(3)) determines whether a new event represents forward progress against every already-Processed event for the same ProviderMessageId; an event that doesn't advance the state is marked Ignored, not Processed.
  • Terminal-state protection: Failed/Rejected are always-terminal - once either is Processed for a ProviderMessageId, no later event (of any type) can be Processed again.
  • Duplicate webhook harmless: covered by the same PayloadHash idempotency.

No provider-specific public endpoint exists - "do not expose a provider-specific public endpoint unless a provider has actually been selected." There is nothing real to verify a signature against yet. Once a provider is chosen, wiring its webhook is: a controller that reads the raw body, verifies that provider's own signature scheme, extracts (providerMessageId, eventType), and dispatches ProcessMessageDeliveryReceiptCommand - mirroring RazorpayWebhookController's exact relationship to ProcessRazorpayWebhookEventCommand.

8. OTP readiness

Architecture only - OtpChallenge, IOtpChallengeRepository, and OtpService/IOtpService are fully built and tested, but deliberately not wired to any controller or auth flow ("add architecture only, not a new auth flow"). No OTP-related endpoint exists in this tranche.

Requirement Implementation
Expiry OtpChallenge.ExpiresAt, checked before honoring a match
Hashed OTP at rest CodeHash via IIdentityService.HashPassword (BCrypt, cost 12 - the same as account passwords); plaintext exists only transiently in OtpService, never persisted
Resend cooldown OtpOptions.ResendCooldownSeconds, checked against the latest challenge's CreatedAt
Attempt limit / brute-force protection OtpChallenge.MaxAttempts + IOtpChallengeRepository.TryRecordVerifyAttemptAsync's optimistic-concurrency (compare-and-swap on AttemptCount) atomic UPDATE - see below
Rate limit OtpOptions.MaxRequestsPerWindow/RateLimitWindowMinutes, via IOtpChallengeRepository.CountSinceAsync
Anti-enumeration OtpService.VerifyOtpAsync returns the same generic message for "no challenge was ever requested" and "a challenge exists but the code is wrong/expired" - OtpVerifyOutcome stays granular for internal logging only
Replay prevention Single-use: Status only ever leaves Pending once (to Verified/Expired/Locked); a second verify against an already-Verified challenge returns AlreadyUsed

Concurrency-safety improvement over the earlier design: the reference implementation (commit f1f4f2a) verified via a plain load-mutate-SaveChangesAsync round trip - two genuinely concurrent verify attempts against the same challenge could race an AttemptCount increment (a real brute-force-protection gap: a lost update could let more wrong guesses through than MaxAttempts intends). This tranche's TryRecordVerifyAttemptAsync instead reads (Status, AttemptCount, MaxAttempts, ExpiresAt), computes the resulting transition in C#, and writes via a conditional UPDATE ... WHERE Id=@id AND Status='Pending' AND AttemptCount=@expected - if another concurrent call already changed either, this affects zero rows and the method retries against fresh state (bounded, 10 iterations). Proven under real MySQL/InnoDB concurrency - see "Concurrency evidence" below. The transition computation is inlined in the repository (not called via OtpChallenge.ComputeVerifyOutcome on a rehydrated instance, since EF Core's ExecuteUpdateAsync cannot call an entity method inside its SetProperty lambdas) - the same established, accepted duplication pattern EmailDeliveryRepository.TryFinalizeFailureAsync already uses, per that method's own doc comment.

9. Admin/ops visibility

AdminMessagingController (/api/admin/messaging/*, [Authorize(Roles = "Admin")]), read-only:

Endpoint Returns
GET /deliveries/failed Paged MessageDeliveryAttempt rows, Status=Failed
GET /deliveries/unknown-outcome Paged rows, Status=UnknownOutcome
GET /deliveries/pending Paged rows, Status=Pending
GET /deliveries/retrying Paged rows, Status=Retrying
GET /consent/{userId} Latest consent state per ConsentType for one user
GET /templates/sms All SmsTemplate rows
GET /templates/whatsapp All WhatsAppTemplate rows

No mutation endpoint (template registration/activation) exists yet - per "do not build a large admin UI unless trivial," registering a template is expected to be done directly against these tables (migration/seed/direct DB access) until a real provider is selected and a proper CRUD surface is worth building.

Financial/data-safety note

No monetary values are involved in this tranche - MessageDeliveryAttempt/OtpChallenge/ NotificationConsent carry no currency amounts. The closest analogue to the settlement/refund concurrency work in Seller Settlements is OtpChallenge's brute-force-protection CAS, addressed above.

Tests

  • PhoneNumberNormalizationTests.cs (SQLite-free, pure) - +91/leading-zero/spaces-dashes/ invalid-length/malformed-input scenarios, "never silently rewrites an ambiguous input."
  • NotificationConsentGateTests.cs (pure) - consent-absent-blocks-marketing, withdrawn-blocks, transactional-routing, Otp-always-allowed, Marketing/Transactional type independence.
  • WhatsAppSmsReadinessTests.cs (SQLite) - enqueue idempotency, claim/finalize lifecycle, stale-lease sweep, channel-scoped candidate queries, full channel-integration tests (template gating, phone gating, consent gating end-to-end through the real SmsNotificationChannel/ WhatsAppNotificationChannel), delivery-receipt idempotency/out-of-order/terminal-state protection, and the full OTP scenario set (expiry, hashing, cooldown, rate limit, brute-force lockout, replay prevention, anti-enumeration).
  • MySqlWhatsAppSmsReadinessConcurrencyTests.cs ([Trait("Category","mysql-smoke")], MYSQL_SMOKE_CONNSTR-gated, [Collection("MySqlSmoke")] so it never races another mysql-smoke class's own setup against the shared database) - real InnoDB proof of: two concurrent workers claiming the same MessageDeliveryAttempt (exactly one wins); stale-lease sweep then reclaim (never two live owners); two concurrent identical enqueues (exactly one succeeds); two/three genuinely concurrent OTP verify attempts against the same challenge (no lost AttemptCount update; exactly one caller observes the lockout transition, never zero, never more than one).
  • MigrationOnlyAndRuntimeValidationTests.cs (extended) - WhatsApp/SMS fail-closed startup validation: enabled with no/placeholder BusinessAccountId/DltEntityId/SenderHeaderId throws outside Development; disabled never requires them; enabled with real values does not throw.

Validation performed

  • dotnet build ECommerce.sln --configuration Release - 0 warnings, 0 errors.
  • Full default backend suite (--filter "Category!=mysql-smoke") - 872/872 passed (799 pre-existing baseline + 73 new).
  • Notification/WhatsApp/SMS/OTP/consent/phone-focused filter - 135/135 passed.
  • Real-MySQL concurrency suite (--filter "Category=mysql-smoke", against a disposable, freshly- migrated container) - 26/26 passed (21 pre-existing + 5 new); the 5 new tests additionally run 3x in isolation with no flakes.
  • Migration AddWhatsAppSmsReadiness - the full 20-migration chain applies cleanly via dotnet ef database update to an empty database (validates the whole chain, not just this one migration); confirmed no pending model changes via a throwaway dotnet ef migrations add producing an empty Up/Down, then removed.
  • git diff --check clean; mkdocs build --strict exit 0.

What is provider-ready

  • The full application-side pipeline (enqueue -> claim -> lease -> dispatch -> finalize -> retry -> ambiguous-outcome) works end to end today, against NoopWhatsAppSender/NoopSmsSender - every candidate is claimed and immediately released back to Pending uncounted (the ProviderDisabled path, identical to SesEmailSender's convention), so the machinery is fully exercised with nothing real behind it.
  • Consent gating, phone normalization, template-existence gating, and idempotent enqueue are all real, live logic - not stubs - already governing every WhatsApp/SMS send this codebase enqueues today (for the notification types in MessageTemplateCatalog's allowlist).
  • OTP request/verify is fully implemented and tested, ready to be wired into a real auth flow.
  • Delivery-receipt processing (idempotency/out-of-order/terminal-state) is fully implemented and tested against synthetic payloads.

What remains provider-specific

  • No real IWhatsAppSender/ISmsSender implementation - a future PR implements these two interfaces against Meta Cloud API/a BSP, and MSG91/Exotel/Gupshup/AWS SNS respectively, and changes only the DI registration in DependencyInjection.cs.
  • No real, provider-approved template rows - SmsTemplate/WhatsAppTemplate are empty until an operator registers real, DLT-registered/Meta-approved content (this codebase deliberately never invents or hard-codes any).
  • No real webhook endpoint - deferred until a provider is selected (see "Delivery receipts/ webhooks foundation" above).
  • No real auth-flow integration for OTP - deferred, "architecture only" per the tranche brief.
  • Template-parameter rendering is a placeholder - MessageDeliveryBackgroundService renders every template parameter as an empty string today (see its own doc comment); real notification-field-to-parameter mapping needs real templates with defined variable semantics first.

Known limitations / follow-ups (P2/P3)

  1. P2 - OTP rate-limit count query is a full-table COUNT per request (IOtpChallengeRepository.CountSinceAsync), not yet load-tested against production OTP volume; the (Phone, Purpose, CreatedAt) index should keep this cheap at expected scale, but this has not been benchmarked.
  2. P2 - OtpService.RequestOtpAsync's resend-cooldown check is a plain read-then-create, not CAS-protected (unlike the verify path, which is fully CAS-protected) - a genuine race between two simultaneous resend requests could both pass the cooldown check and both create a challenge. Accepted as lower priority: the worst case is one extra SMS/WhatsApp send, not a security or financial exposure (contrast with the verify path's brute-force protection, which is CAS-protected because a race there has real security consequences).
  3. P3 - No real-MySQL concurrency test for the delivery-receipt out-of-order/terminal-state logic - proven correct at the SQLite/logic tier only; the underlying primitive (TryRecordAsync's PayloadHash uniqueness) is the same pattern already proven under real MySQL for RazorpayWebhookEvent and MessageDeliveryAttempt.CorrelationKey, so this is a lower-risk gap than the OTP/claim races (which are proven under real MySQL).
  4. P3 - MessageTemplateCatalog's notification-type allowlist is a fixed, hand-maintained set (mirrors EmailTemplateCatalog's identical, pre-existing convention) - adding a new WhatsApp/SMS-worthy event requires a code change, not configuration.