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
PayloadHashuniqueness. - 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-Processedevent for the sameProviderMessageId; an event that doesn't advance the state is markedIgnored, notProcessed. - Terminal-state protection:
Failed/Rejectedare always-terminal - once either isProcessedfor aProviderMessageId, no later event (of any type) can beProcessedagain. - Duplicate webhook harmless: covered by the same
PayloadHashidempotency.
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 realSmsNotificationChannel/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 sameMessageDeliveryAttempt(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 lostAttemptCountupdate; 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/placeholderBusinessAccountId/DltEntityId/SenderHeaderIdthrows 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 viadotnet ef database updateto an empty database (validates the whole chain, not just this one migration); confirmed no pending model changes via a throwawaydotnet ef migrations addproducing an emptyUp/Down, then removed. git diff --checkclean;mkdocs build --strictexit 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 toPendinguncounted (theProviderDisabledpath, identical toSesEmailSender'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/ISmsSenderimplementation - 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 inDependencyInjection.cs. - No real, provider-approved template rows -
SmsTemplate/WhatsAppTemplateare 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 -
MessageDeliveryBackgroundServicerenders 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)¶
- P2 - OTP rate-limit count query is a full-table
COUNTper 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. - 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). - 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'sPayloadHashuniqueness) is the same pattern already proven under real MySQL forRazorpayWebhookEventandMessageDeliveryAttempt.CorrelationKey, so this is a lower-risk gap than the OTP/claim races (which are proven under real MySQL). - P3 -
MessageTemplateCatalog's notification-type allowlist is a fixed, hand-maintained set (mirrorsEmailTemplateCatalog's identical, pre-existing convention) - adding a new WhatsApp/SMS-worthy event requires a code change, not configuration.
Related pages¶
- Production Communications
- Email Delivery Hardening
- Marketplace Notifications
- Payment Production Hardening (the Razorpay webhook/refund
tranche this one's
MessageDeliveryReceipt/idempotency design directly mirrors)