Short answer: keep the verification template in your application when wording, localization, and auditability are product work; delegate delivery and carrier suppression to a messaging API. For a one-person SaaS handling an edtech signup link or a warehouse pickup code, that split protects revenue-per-hour without hiding the operational rules.
A verification message looks small. It is not. The code expires, the link must be single-use, and an SMS may be delayed. Template ownership decides who can change copy, who can prove what was sent, and who carries the cost of a bad edit. In a signup flow, the message is one record in a longer chain: an account id creates an attempt, a policy check decides whether the destination is eligible, a renderer selects locale and channel, a transport accepts the payload, and later events revise the attempt state. Each handoff needs a stable id and a timestamp. Without those fields, a support agent cannot tell the difference between a user who never requested a code, a message that was suppressed before send, and a carrier that has not reported delivery yet. That ambiguity is expensive for a small product because the fastest support answer becomes a resend, and resends can create duplicate codes, duplicate charges, or a lockout that looks like a security feature. I keep the original attempt immutable and attach every retry as a child event. It makes the audit trail longer, but the decision is visible months later when copy, routing, or consent rules have changed.
| Pattern | Template owner | Best fit | Main liability |
|---|---|---|---|
| App-rendered | Your service | Product-led copy and strict audit trails | You own localization and escaping |
| Provider-rendered | Messaging service | Tiny team with stable, generic copy | Copy changes follow provider tooling |
| Hybrid | App plus provider fallback | Different channels or regulated wording | Two version systems must stay aligned |
My default is hybrid in behavior but single-source in data: store a versioned message intent in your app, render channel-specific content there, and pass the result to a delivery API that handles carrier policy and suppression. That gives me a weekly shipping rhythm. It also outsources the undifferentiated queue work.
What should a beginner choose for SMS OTP, suppression, and status polling?
Start with the state machine, then pick an API shape. A beginner stack should represent issued, queued, sent, delivered, failed, suppressed, and expired explicitly. Do not infer delivery from an HTTP 202 response. That response means the request was accepted, not that a phone received anything.
Suppression is a policy decision. Keep a durable record keyed by a normalized destination and a reason such as user_opted_out, carrier_blocked, or too_many_attempts. Check it before issuing a code and again before retrying. A resend button should create a new attempt with a new idempotency key, not mutate the old attempt.
Status polling belongs behind your own endpoint. The browser can poll /api/verification-attempts/{id} every few seconds with exponential backoff, while your worker consumes provider callbacks when available. Polling is the fallback for a provider that exposes status but no webhook, and it is also useful for a warehouse scanner that cannot receive inbound callbacks. Cap the lifetime; a five-minute code should not produce a twenty-minute polling loop.
The important boundary is ownership. The app owns the meaning of the message: locale, template version, code lifetime, and whether a link or numeric OTP is appropriate. The delivery layer owns transport concerns: rate limits, carrier response mapping, and suppression feeds.
The two criteria that change the decision
The first criterion is change velocity. If growth experiments routinely change the call to action, application rendering wins because a pull request can update copy, tests, and analytics together. If legal wording is frozen for a year, provider rendering can reduce moving parts.
The second is evidence. Support eventually asks, “Which text did this learner see?” An event record should answer with a template id, locale, channel, attempt id, and timestamps. Store the rendered payload only when policy permits it; otherwise store a content hash and the variables needed for reconstruction. Email open events are weak evidence because Apple Mail Privacy Protection can prefetch tracking pixels. Delivery and click events are stronger signals for this workflow.
I treat these as an operating budget. Every manual console edit is a future incident review, and every untested template branch steals time from shipping a paid feature. Ive found that a 15-minute review of the event schema saves a much longer support thread later. Your mileage may vary if a compliance team owns copy approvals, but the evidence requirement does not disappear.
A small Node.js state machine for signup links
The code below keeps transport generic. It uses the Fetch API, so the same adapter can call an HTTP service from Node.js without installing a vendor SDK. The endpoint names are placeholders owned by your adapter, not a recommendation of a commercial route.
type AttemptState =
| 'issued'
| 'queued'
| 'sent'
| 'delivered'
| 'failed'
| 'suppressed'
| 'expired';
type VerificationAttempt = {
id: string;
destination: string;
channel: 'sms' | 'email';
templateVersion: string;
state: AttemptState;
expiresAt: string;
};
export async function createAttempt(
baseUrl: string,
token: string,
input: { destination: string; channel: 'sms' | 'email'; locale: string },
): Promise<VerificationAttempt> {
const response = await fetch(`${baseUrl}/verification-attempts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
...input,
templateVersion: 'signup-link-v3',
purpose: 'account_signup',
}),
});
if (!response.ok) {
throw new Error(`verification request failed: ${response.status}`);
}
return (await response.json()) as VerificationAttempt;
}
The adapter should map transport events into the same internal states for an SMS OTP and an email link. A suppressed event is not a transport crash; it is a deliberate decision to stop sending. Show a useful recovery path, such as changing the destination or contacting support, without revealing whether an account exists.
For warehouse pickup codes, use the same state machine but a shorter display surface: a scanner can poll the attempt id, while the fulfillment screen displays only the last four characters of a masked destination and the expiry time. Never put the full code in logs.
Testing the failure modes before launch
A happy-path test proves very little. I keep fixtures for a delayed carrier, duplicate callbacks, an already-suppressed destination, a locale with longer text, and a user who requests resend at the expiry boundary. Callback handlers must be idempotent; the same delivered event can arrive twice or after failed because systems observe events at different times.
Property-based tests are useful for normalization. Phone numbers should be stored in a canonical form, while email comparisons should follow the account policy rather than a casual lowercase operation. Rate limits need a clock abstraction so a test can advance time without sleeping.
Observability should measure time between issued and delivered, suppression rate by reason, resend count, and the share of attempts that expire. Break those metrics down by country and channel. A single global success rate can hide a regional carrier problem.
One practical trap: status polling can become a thundering herd after a frontend reconnect. Add jitter, honor Retry-After when exposed, and stop polling after the expiry deadline. I once assumed a 202 meant the message was effectively done; the missing delivery transition made the signup screen look successful while learners waited. The fix was a state transition, not a larger timeout.
When the runner-up is the better fit
Provider-rendered templates are reasonable when a non-engineering team must approve every wording change and the product has only one locale. They are less suitable when your signup flow shares text with in-app help, transactional email, and a warehouse device; duplicating variables across consoles makes drift likely.
App-rendered templates are a poor fit when your team cannot staff carrier policy updates, opt-out handling, or regional sender registration. In that case, pay for a delivery layer with those operational controls and keep only the message intent plus version in your database.
The catch is that no pattern removes compliance work. You still need consent records, retention rules, access controls, and a clear escalation path for blocked destinations. Pick the boundary that leaves the fewest invisible decisions in a console.
For a solo founder, that is the real optimization. Ship the first version with a boring state machine, observable transitions, and one template source. Revisit the split when copy approvals or regional volume actually changes the workload.
Top comments (0)