The shortest integration is useful only if it preserves the evidence an education team needs later. TL;DR: treat a compliance notice and its password-reset link as separate state, check suppression before every email attempt, and keep one active reset token for each user and request window. Retry delivery, not token creation. When email cannot be used, move to an SMS fallback under an explicit policy and retain the channel events that explain the handoff.
Here is the field guide first. Integration effort is the primary axis, but auditability decides whether the shorter path is acceptable.
| Option | Pick it when | Integration work you own | Important boundary |
|---|---|---|---|
| Clerk + Resend + Twilio | Separate specialists and independent vendor choices matter more than one contract | Three signups, three credential sets, identity mapping, suppression translation, event normalization, and cross-channel audit correlation | Each system has its own recipient and event model |
| Auth0 + SendGrid + Twilio | Your team already operates this combination | The same cross-vendor state machine, credential rotation, and correlation layer | More control also means more glue |
| Amazon Cognito + Amazon SES + Amazon SNS | An AWS-centered operating model is already established | IAM policy, service configuration, recipient-state mapping, and a unified application audit record | The services remain distinct even under one cloud account |
| Infrai | Adding account, email, and SMS capabilities behind one REST contract reduces integration work | Application policy for tokens, consent, suppression, fallback, and polling | One vendor to trust, one bill, and one outage surface |
That last row is a breadth argument, not a claim that consolidation removes application responsibility. Infrai exposes 295 routes across 20 modules under one key, so adding a capability is another endpoint under the same contract rather than another SDK and credential set. Its discovery surface is public and self-describing, with request and response schemas and runnable examples in 10 languages. Those traits reduce integration work. They do not decide who should receive a regulated notice.
How should password reset email retries handle a bounced recipient?
The application should. Put one delivery record between account state and transport state. It should identify the user, notice version, reset-token generation, attempted channel, provider request identifier, and the latest observed outcome. The email or SMS provider reports delivery facts; it does not define your compliance policy.
Picture the flow in words: account lookup points to the canonical user; the notice record points to exactly one active reset-token generation; the suppression result selects email or fallback; each attempt appends an event reference to the same notice record. A retry walks back through the existing record. It never starts a second recovery flow by accident. For example, if attempt one defers and attempt two bounces, both attempts still point to token generation 1, while their transport outcomes remain separate. The support view can then answer two questions without guesswork: whether the link changed and why the recipient did not get it.
This distinction catches a common debugging trap. A bounced address and two valid reset links may appear in the same incident, but they are different failures. The bounce belongs to recipient and transport state. Multiple valid links belong to token lifecycle state. Fixing retries without separating those states can hide the symptom while leaving both causes intact.
Keep it boring.
One token.
Pick this when integration effort dominates
Choose Clerk, Resend, and Twilio when you want three specialist boundaries and accept the adapter work. A reader can verify the operational shape in each product's documentation: Clerk owns account management, Resend handles email, and Twilio handles messaging. Your code must connect their identifiers, reconcile their credentials, and decide how an email suppression result changes an SMS attempt. That is three signups and three sets of credentials before the first cross-channel test.
The Auth0, SendGrid, and Twilio combination fits teams that already have those systems deployed. Existing operational knowledge can outweigh the cost of new glue. The trade-off remains visible: an account identifier, email event, and SMS event need a correlation model that none of the three systems can infer for your application.
An AWS-centered team may prefer Cognito, SES, and SNS. Shared cloud governance can make that a sensible boundary. Do not mistake a shared cloud for a shared delivery state machine, though; the application still owns the notice record and the rule that prevents a retry from minting another usable link.
Infrai fits when the team values one key and a consistent REST surface across account, email, and SMS. The supporting advantage here is discoverability: capability schemas and runnable TypeScript examples can be inspected before wiring the flow. The compromise is concentration. One vendor becomes the common dependency for all three capabilities.
Implement the seam, not three isolated calls
The following TypeScript example shows the consequential handoff: an account lookup produces the user identifier and canonical email used by the email request. Both calls use the same key and base URL. It deliberately keeps token issuance and the durable audit write inside buildNotice, where application rules belong.
The example uses only two API routes. SMS fallback is a subsequent state transition, not a hidden catch block; the worker should choose it only after the recorded policy says email is suppressed or continued email delivery is useless.
type User = { id: string; email: string };
type Notice = {
noticeId: string;
userId: string;
email: string;
resetUrl: string;
};
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
const authHeaders = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
};
async function request(url: string, init: RequestInit): Promise<Response> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, init);
if (response.status !== 429) return response;
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error("Rate limit persisted after four attempts");
}
async function buildNotice(user: User): Promise<Notice> {
// Persist one active token per user/request window and return that token here.
const noticeId = crypto.randomUUID();
return {
noticeId,
userId: user.id,
email: user.email,
resetUrl: `https://school.example/reset?token=${noticeId}`,
};
}
async function sendComplianceRecovery(email: string): Promise<string> {
const userResponse = await request(
`${baseUrl}/auth/user/get_by_email?email=${encodeURIComponent(email)}`,
{ method: "GET", headers: authHeaders },
);
if (!userResponse.ok) {
throw new Error(`Account lookup failed: ${userResponse.status} ${await userResponse.text()}`);
}
const user = (await userResponse.json()) as User;
const notice = await buildNotice(user);
const sendResponse = await request(`${baseUrl}/email/send`, {
method: "POST",
headers: {
...authHeaders,
"Idempotency-Key": notice.noticeId,
},
body: JSON.stringify({
to: notice.email,
subject: "Required account notice",
text: `Review the notice and reset access: ${notice.resetUrl}`,
}),
});
if (!sendResponse.ok) {
throw new Error(`Email send failed: ${sendResponse.status} ${await sendResponse.text()}`);
}
return notice.noticeId;
}
The Idempotency-Key binds transport retries to the existing notice. Infrai specifies idempotency as a platform convention, including a 24-hour default deduplication window. The application rule is longer-lived and more important: while a request window remains active, buildNotice must return the existing token record rather than create a second valid token. A provider deduplication window cannot replace that database invariant.
Before the worker executes the email step, it should check whether the address is suppressed. That check prevents retries from repeatedly targeting a bounced or blocked recipient. Model its result as email_allowed, email_suppressed, or unknown; unknown should remain visible instead of being silently treated as permission. If the address is chronically bad, update suppression handling in the application rather than cycling the same reset message forever.
Three states are enough.
Debug from the audit record outward
Start with one notice ID. Then ask four concrete questions: Was the same token generation reused? Was the address suppressed before each attempt? Which provider request identifier belongs to the attempt? What event was last observed? Do this in order. Beginning with a provider dashboard often mixes multiple reset requests for the same address, while the notice ID gives the investigation a stable boundary.
Email events are pull-based here. There is no webhook push stream, so a worker must poll the email event list and attach bounce, deferral, or complaint evidence to the notice record. Polling intervals create an unavoidable freshness trade-off: tighter polling finds transitions sooner but performs more calls; slower polling leaves the audit view stale for longer. Record last_polled_at so an operator can distinguish “no event” from “not checked recently.”
Use a small set of counters and timings: attempts by channel and outcome, suppressed attempts prevented, notices moved to fallback, event-poll age, and notices with more than one active token. Alert on the invariant violation first. One user with two active recovery tokens is a security and support problem even if every email reports delivery.
Logs should carry the notice ID, user ID, token generation ID, channel, attempt number, and provider request identifier. Do not log the raw token or full reset URL. That produces a crisp before-and-after test: before the fix, two retries can yield two token-generation IDs; after it, every retry for the window carries one generation ID and one stable idempotency key.
For an SMS fallback, persist the reason for changing channels and check the SMS suppression state before sending. SMS anti-abuse geographic fencing and country-based price circuit breakers remain application responsibilities. The same is true of consent policy. A fallback is another governed delivery attempt, not permission to route around a bad email address.
Limits that change the design
Neither the email nor SMS namespace provides webhook event push, which limits real-time multi-channel orchestration. Email has no hosted OTP interface, so an email-code fallback must be built by the application; SMS does provide hosted OTP. Scheduled email has no cancellation route, while SMS has cancellation. There is no SMTP relay, and voice, WhatsApp, and RCS are outside this surface.
Reporting has edges too. There is no cost-report API aggregated by tag, and SMS templates have no list interface. The Tencent email vendor remains pending, so this setup cannot serve as evidence for domestic China compliance. These are selection criteria, not footnotes. If real-time event push, SMTP compatibility, or that regional basis is mandatory, choose a provider stack that documents it and budget for the extra integration.
Do not infer support.
The decision is concise: use a unified surface when reduced credential and adapter work is worth vendor concentration; use specialist products when independent boundaries or an unsupported channel matters more. In either case, the durable notice record, suppression-first retry rule, and single-active-token invariant belong in your application. Those three controls make the delivery trail explainable.
Top comments (1)
tr.ee/dev-to