The least complex useful Node.js integration is one command handler that owns signup policy and one narrow SMS adapter that sends or reads delivery state. TL;DR: decide country eligibility, rate limits, cancellation, and resend rights before calling the adapter; treat delivery polling as evidence about an attempt, never as permission to verify an account.
For a fintech signup, this keeps the sensitive decision in code you control. The request becomes a verification challenge, the challenge may create message attempts, and a worker sends or polls those attempts. Clicking the link completes the challenge only after the application validates its token and current state. A late delivery update can change an attempt from sent to delivered, but it can't revive an expired or canceled signup.
That boundary is small enough to ship. It also avoids spreading country checks and retry counters across controllers, queue workers, and callback handlers.
How should Node.js SMS event notification alerts handle delivery?
Put business inputs into a versioned policy snapshot when the challenge is opened: the declared market, the destination market, an expiry, an attempt ceiling, a resend cooldown, and the polling interval. The values below are example application settings, not legal requirements or universal defaults. A compliance owner must approve consent, sender identity, content, retention, and country availability before a market is enabled.
Do not infer policy from a phone prefix alone. Require the declared signup market and normalized destination market to select one enabled configuration. A mismatch should fail before enqueueing, with a stable reason that support and metrics can distinguish from a transport failure.
The adapter needs only two operations. send accepts an idempotency key; readDelivery maps transport-specific results into the application's few delivery states. That is the deliberate trade-off: some mapping work at the edge buys a much smaller integration surface everywhere else.
Keep it boring.
Run the decision path before adding infrastructure
This TypeScript example is intentionally in memory. It exposes the decisions first, so the same cases can be tested before a database or queue obscures them. The sample uses a 10-minute challenge, a 60-second resend cooldown, two attempts, and 30-second polling. Those are visible test settings. They need explicit product and risk approval before production use.
type Market = "US" | "EU";
type ChallengeState = "open" | "verified" | "canceled" | "expired";
type DeliveryState = "queued" | "sent" | "delivered" | "failed" | "unknown";
interface Policy {
version: string;
enabled: boolean;
maxAttempts: number;
resendAfterMs: number;
pollAfterMs: number;
}
interface Challenge {
id: string;
market: Market;
destinationMarket: Market;
state: ChallengeState;
expiresAt: number;
policy: Policy;
}
interface Attempt {
id: string;
challengeId: string;
sequence: number;
createdAt: number;
delivery: DeliveryState;
providerRef?: string;
nextPollAt?: number;
}
interface SmsAdapter {
send(input: {
to: string;
body: string;
idempotencyKey: string;
}): Promise<{ providerRef: string; delivery: DeliveryState }>;
readDelivery(providerRef: string): Promise<DeliveryState>;
}
const policies: Record<Market, Policy> = {
US: {
version: "signup-us-v1",
enabled: true,
maxAttempts: 2,
resendAfterMs: 60_000,
pollAfterMs: 30_000,
},
EU: {
version: "signup-eu-v1",
enabled: false,
maxAttempts: 2,
resendAfterMs: 60_000,
pollAfterMs: 30_000,
},
};
function openChallenge(input: {
id: string;
market: Market;
destinationMarket: Market;
now: number;
}): Challenge {
const policy = policies[input.market];
if (!policy.enabled) throw new Error("market_disabled");
if (input.market !== input.destinationMarket) {
throw new Error("market_mismatch");
}
return {
id: input.id,
market: input.market,
destinationMarket: input.destinationMarket,
state: "open",
expiresAt: input.now + 10 * 60_000,
policy: { ...policy },
};
}
function planAttempt(input: {
challenge: Challenge;
previous: Attempt[];
attemptId: string;
now: number;
}): Attempt {
const { challenge, previous, now } = input;
if (challenge.state !== "open" || challenge.expiresAt <= now) {
throw new Error("challenge_inactive");
}
if (previous.length >= challenge.policy.maxAttempts) {
throw new Error("attempt_limit_reached");
}
const latest = previous.at(-1);
if (latest && now < latest.createdAt + challenge.policy.resendAfterMs) {
throw new Error("resend_too_soon");
}
return {
id: input.attemptId,
challengeId: challenge.id,
sequence: previous.length + 1,
createdAt: now,
delivery: "queued",
};
}
async function sendAttempt(input: {
challenge: Challenge;
attempt: Attempt;
to: string;
verificationUrl: string;
now: number;
sms: SmsAdapter;
}): Promise<Attempt> {
if (input.challenge.state !== "open" || input.challenge.expiresAt <= input.now) {
throw new Error("challenge_inactive");
}
const result = await input.sms.send({
to: input.to,
body: `Verify your account: ${input.verificationUrl}`,
idempotencyKey: input.attempt.id,
});
const terminal = result.delivery === "delivered" || result.delivery === "failed";
return {
...input.attempt,
providerRef: result.providerRef,
delivery: result.delivery,
nextPollAt: terminal ? undefined : input.now + input.challenge.policy.pollAfterMs,
};
}
async function pollAttempt(input: {
challenge: Challenge;
attempt: Attempt;
now: number;
sms: SmsAdapter;
}): Promise<Attempt> {
const { challenge, attempt, now, sms } = input;
if (challenge.state !== "open" || challenge.expiresAt <= now) return attempt;
if (!attempt.providerRef || ["delivered", "failed"].includes(attempt.delivery)) {
return attempt;
}
const delivery = await sms.readDelivery(attempt.providerRef);
const terminal = delivery === "delivered" || delivery === "failed";
return {
...attempt,
delivery,
nextPollAt: terminal ? undefined : now + challenge.policy.pollAfterMs,
};
}
Run openChallenge, planAttempt, and sendAttempt in separate transactions or jobs in a real system. Reserve the (challenge_id, sequence) pair with a uniqueness constraint. Two resend requests may arrive together; only one should reserve the next sequence. The stable attempt ID then serves as the send idempotency key when a worker is replayed.
Keep the verification token out of logs and store a digest rather than the raw token. The link should use HTTPS, be single-use, and be accepted only while its challenge remains open and unexpired. Delivery is not identity proof.
Separate cancellation from transport status
Cancellation changes the challenge first. A queued worker must re-read that state before sending, and the polling scheduler should stop creating work for the challenge. If a message has already left the application, cancellation cannot promise to pull it back; it can make the enclosed link unusable.
Resend is a new attempt, not a mutation of the old one. It must pass the same country check, expiry check, cooldown, attempt ceiling, and abuse controls. A failed or unknown delivery state can inform the decision, but it shouldn't silently manufacture another message. That distinction matters when polling results arrive late or out of order.
Short version: challenge state owns access; attempt state owns transport history.
| Record | Owns | Never decides |
|---|---|---|
| Challenge | Verification, cancellation, expiry | Carrier delivery |
| Attempt | One dispatch and its observed status | Account access |
| Policy snapshot | Market, limit, and timing inputs | The result of an in-flight attempt |
This split also makes status updates safe to repeat. Applying delivered twice should leave the same attempt state, while applying it after cancellation should preserve both facts: the message was delivered and the challenge was canceled. Neither fact erases the other. An operator can then explain what happened without inventing a single status that tries to represent transport, authentication, and policy at once.
Make rate limits explainable
A single global counter is easy to add and hard to reason about. Separate application counters can represent the normalized destination, the signup session or account, and a network risk signal; each corresponds to a different abuse path. Exact thresholds belong in versioned policy rather than scattered literals in handlers. When a request is denied, record an opaque reason code and policy version, not the token or full verification URL.
Country guardrails deserve the same treatment. Start disabled, enable only an approved policy version, and attach that snapshot to new challenges. Disabling a market should block new sends without rewriting the history of attempts already made. This is governance through ordinary data, which is much easier to review than a country-shaped branch buried inside an adapter.
There is a cost trade-off. Polling every nonterminal attempt forever wastes queue capacity and transport calls, so stop at the challenge expiry or an explicit observation deadline. Faster polling may produce fresher dashboards, but it doesn't make the verification decision more correct. Pick the interval from the operational need, then measure queue age and outstanding poll work.
No receipt changes that rule.
Ship with transition tests, not a happy-path demo
The deployment checklist is prose because the order matters. First, test a disabled market and a destination mismatch and confirm neither creates an attempt. Next, race two resends and confirm the sequence constraint admits one. Cancel after enqueue but before dispatch; expire during polling; replay a completed send job with the same attempt ID; then deliver an old status after cancellation. In every case, the challenge state must remain authoritative.
Watch challenges opened, attempts reserved, sends accepted, mapped delivery states, verification completions, expirations, cancellations, and queue age. Group them by opaque policy version and attempt ID. A rise in unknown mappings points to the adapter boundary; a rise in queue age points to worker throughput; a gap between delivery and verification is a product observation, not automatic evidence of transport failure.
The shipping rule is plain: keep the adapter replaceable and the signup decision local. This takes more work than placing a send call in the registration controller, but less work than debugging a system where late transport events can alter account state. For a fintech verification link, that is the integration effort worth paying.
Further reading
- Google, "Email sender guidelines": https://support.google.com/a/answer/81126
- Anthropic, "Tool use overview": https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview
Top comments (0)