Healthtech Receipt Template Governance for SMS OTP Phone Login Explained

go dev.to

Short answer: Let the identity team own the SMS OTP template and resend policy, let the payment domain own the order-receipt template, and make the backend authoritative for every countdown and delivery transition. A Next.js button may display retry_at; it must not decide when another SMS is allowed. This ownership split keeps a login retry from becoming an accidental receipt retry after payment settles.

Treat templates as versioned production artifacts, not strings tucked inside a UI component. The owner approves variables, compatibility, and rollback; a shared delivery layer accepts an immutable render request and records its idempotency key. That gives an on-call engineer one useful answer during an incident: which team can change this message without redeploying an unrelated workflow?

If those responsibilities are currently centralized, migrate authority one artifact at a time. Shadow-rendering a new receipt template is safe; letting both old and new owners schedule customer messages is not.

I've been paged by missed jobs and duplicate deliveries. The common operational smell is ambiguous authority — two components each believe they are allowed to schedule the same side effect.

Implementation inventory for a template ownership migration

There are three decisions hiding behind “send a message.” The identity domain decides whether a login challenge may send another code. The payment domain decides that a settled payment requires an order receipt and supplies the receipt data. The delivery layer decides how an accepted, immutable message job moves through transport. Trouble starts when a UI timer, an API handler, and a queue worker can each schedule what they regard as the same send, or when a centrally edited receipt template accepts fields that the payment event does not guarantee. The visible symptom may be a duplicate SMS or a missing receipt, but the useful diagnostic question is about authority: which component owned the transition, which immutable input authorized it, and which idempotency record proves the decision? Each answer needs one accountable owner.

One transition, one owner.

For this healthtech flow, keep the OTP copy and its allowed variables with identity. Keep the receipt body, localization, and payment-event schema contract with payments or a communications team explicitly delegated by payments. A central design or compliance group can review both without becoming the runtime owner of every template. Review authority and deployment authority don't have to be the same thing.

Failure signals during scheduler transfer

Artifact Accountable owner Stable input Independent rollback
Login challenge policy and SMS OTP template Identity Challenge ID and send generation Policy/template version
Settled-payment receipt template Payments Payment event ID and receipt kind Receipt template version
Delivery job contract Messaging platform Message job ID and idempotency key Worker release

During migration, record the intended owner, current owner, active template version, accepted input schema, scheduler, and rollback pointer for each row. Transfer render approval first, then activation authority, and transfer scheduling authority last. The old path may shadow-render the same immutable input for comparison, but only the active scheduler can insert a delivery job. This keeps coexistence observable without making a customer receive both versions.

A single central template repository can be appropriate for a small team with one release process and one on-call rotation. The catch is that central ownership becomes a queue when domain teams cannot validate or roll back their own schema changes. Domain ownership has the opposite cost: it needs enforced metadata, review rules, and a stable renderer contract or every team invents a different delivery model. Don't split repositories just to draw a cleaner diagram. Split authority when change approval, domain data, or on-call responsibility has actually split.

This is governance with runtime consequences. If a receipt variable changes from an optional value to a required one, the template version must declare which event schema it accepts. If an OTP policy changes, existing challenges retain the policy version under which they were issued. Neither release should silently reinterpret work already in flight.

How should backend retries preserve phone verification login countdown state?

The backend stores the challenge state and returns an absolute retry_at timestamp plus its current server_time. Next.js computes a display countdown from those values, disables the resend button while time remains, and asks the backend again after a click. Refreshing the page, opening another tab, or changing the browser clock cannot create eligibility. Authority stays server-side.

Clocks are not authority.

Use one atomic transition to reserve a send generation and insert an outbox job. The transaction should not call an external delivery network while holding a database lock. A worker later consumes the outbox entry, using the challenge ID plus generation as its stable idempotency key. In this example, 429 means the resend window remains closed and 409 means the challenge can no longer transition; those status mappings are application policy, not properties of an SMS provider.

The Go service below keeps the policy explicit and the transport generic. Store.ReserveResend must commit the challenge update and outbox record together.

package login

import (
    "context"
    "errors"
    "fmt"
    "time"
)

var (
    ErrCooldown = errors.New("cooldown_active")
    ErrClosed   = errors.New("challenge_closed")
)

type Challenge struct {
    ID          string
    Phone       string
    Region      string
    Policy      string
    NextSendAt  time.Time
    Generation int
    MaxResends  int
    Verified    bool
}

type MessageJob struct {
    Kind           string
    TemplateVersion string
    Destination    string
    IdempotencyKey string
}

type Store interface {
    // ReserveResend updates the challenge and inserts the job atomically.
    ReserveResend(ctx context.Context, challengeID string, now time.Time,
        build func(*Challenge) (MessageJob, error)) (Challenge, error)
}

type Service struct {
    store           Store
    now             func() time.Time
    cooldown        time.Duration
    OTPTemplate     string
}

type ResendResult struct {
    ServerTime time.Time `json:"server_time"`
    RetryAt    time.Time `json:"retry_at"`
}

func (s Service) Resend(ctx context.Context, challengeID string) (ResendResult, error) {
    now := s.now().UTC()
    challenge, err := s.store.ReserveResend(ctx, challengeID, now,
        func(c *Challenge) (MessageJob, error) {
            if c.Verified || c.Generation >= c.MaxResends {
                return MessageJob{}, ErrClosed
            }
            if now.Before(c.NextSendAt) {
                return MessageJob{}, ErrCooldown
            }

            c.Generation++
            c.NextSendAt = now.Add(s.cooldown)
            return MessageJob{
                Kind:            "login_otp",
                TemplateVersion: s.OTPTemplate,
                Destination:     c.Phone,
                IdempotencyKey: fmt.Sprintf("%s:send:%d", c.ID, c.Generation),
            }, nil
        })

    result := ResendResult{ServerTime: now, RetryAt: challenge.NextSendAt}
    return result, err
}
Enter fullscreen mode Exit fullscreen mode

The important line isn't the timer calculation. It is the storage contract. If the process stops after commit, the outbox row remains available; if two clicks race, only one transaction can reserve the next generation. The client may repeat its request, but it cannot mint another generation by choosing a new browser-side key.

Don't reuse the challenge key for the receipt. The settled-payment consumer should derive a separate key from the payment event ID and receipt kind, then create one receipt job under the template version selected by the payment domain. Login proves control of a phone number for a session; settlement authorizes a receipt. Combining those state machines makes replay analysis needlessly risky.

Implementation contract for version coexistence

US and EU behavior should be selected by backend-owned policy data attached to the challenge, not inferred from browser locale and not hard-coded into the resend button. The specific policy values require security, delivery, and legal review for the deployment; I'm not sure one cooldown or retention rule is appropriate for every risk profile. Abuse evidence, support evidence, and the applicable requirements should resolve that choice. The invariant is narrower and stronger: a challenge records the policy version used for its decisions.

Template contracts deserve the same treatment. A receipt render request should name a template version and carry a documented set of order fields. Before activation, render representative records with absent optional fields, long values, and every supported locale. Activation should move a pointer to an already reviewed version — no editing live content in place — so rollback does not require reconstructing yesterday's bytes from memory.

Keep sensitive values out of routine observability. Do not log OTP values or use full phone numbers as metric labels. Correlate with opaque challenge IDs, payment event IDs, receipt job IDs, template versions, and policy versions. Those identifiers are enough to reconstruct the transition trail without turning a dashboard into another store of customer data.

Compare cutover evidence under concurrent requests

A happy-path test proves almost nothing about scheduling. Start 20 concurrent resend requests against one eligible challenge and assert that exactly one new generation and one outbox job are committed. Repeat from two browser sessions, refresh the page, and move one client clock forward. Every rendered countdown may differ briefly, but backend eligibility and the resulting job count must not.

Then test the ownership boundary rather than only the code path:

  1. Activate a new receipt template against the event schema version it declares, and reject activation when required variables are missing.
  2. Deliver the same settled-payment event twice and assert that one receipt job exists for its event ID and receipt kind.
  3. Verify that changing the OTP policy affects new challenges without reopening a verified challenge or resetting an existing send generation.
  4. Confirm that logs, traces, error reports, and metric labels contain neither OTP values nor full phone numbers.
  5. Roll the receipt template pointer back while leaving the identity policy and queued idempotency records untouched.

Watch accepted resend generations, cooldown rejections, challenge verification outcomes, oldest outbox age, duplicate-suppression counts, and receipt terminal states. A button click is intent, not delivery. Likewise, an email open is not dependable proof that a receipt reached or was read by a person: Apple Mail Privacy Protection can prevent senders from seeing whether a recipient opened a message. Use the durable workflow state as the operational signal.

Mail authentication remains delivery infrastructure. DKIM defines a mechanism for a signer to claim responsibility for a message by adding a domain-linked signature. The receipt template owner should validate content and variables, while the delivery owner maintains signing; coupling a business-copy rollback to signing configuration expands the blast radius for no useful reason.

Failure-safe rollback preserves the ownership ledger

Rollback changes the active template or policy pointer; it does not delete challenge, outbox, or receipt records. For a bad receipt rendering, restore the prior reviewed template version and reprocess only payment events whose idempotent receipt job is absent or explicitly eligible under the runbook. For a login-policy rollback, preserve existing challenge generations and evaluate future transitions under the recorded policy contract.

Rollback is not replay.

Pause a worker only when queue age remains observable and the runbook states the resume condition. Otherwise, a content problem can be converted into an invisible scheduling problem. Preserve the evidence — event ID, job ID, owner, template version, policy version, and transition time — and ask one postmortem question first: which owner-controlled transition violated its invariant?

The client shows time. The backend grants transitions. Template owners control compatible content, and idempotency keeps a rollback from becoming a resend storm.

References

Source: dev.to

arrow_back Back to Tutorials