DEV Community

SilasFletcher5853
SilasFletcher5853

Posted on

2FA SMS OTP API: Retry and Cancel Rules for US/EU Renewal Notices

The integration effort is the deciding factor for a subscription-renewal flow. Short answer: keep OTP state, resend limits, and cancellation in your Node.js application, then put a narrow HTTP adapter in front of the SMS API; use the same notification ledger for renewal notices, but never let a renewal message share an authentication token or retry policy.

Here is the choice matrix I use when a one-person SaaS has to ship weekly:

Approach Best fit Integration cost Main risk to test
Hosted verification workflow You need a prebuilt challenge and delivery state Low initial code, more vendor-specific state Cancellation and account recovery rules may be opaque
Generic SMS API plus app-owned OTP You need precise resend, cancel, and audit behavior Medium, with one adapter and a ledger Your team owns abuse controls and expiry
Self-hosted SMS gateway A regulated environment requires unusual routing control High, including carrier operations Delivery evidence and regional coverage become your problem

I would start with the middle row for a small product. It keeps the business rule in code you can review, while the transport remains replaceable. The trade-off is real: this is not suitable when your team cannot operate rate limits, consent records, and incident response. In that case, stick with a hosted verification workflow and accept its boundaries.

How can a login team connect an SMS OTP API for 2FA retries?

Treat an OTP as a short-lived authorization attempt, not as an SMS message. Store a server-side record with challenge_id, a one-way code hash, purpose, destination region, expiry, attempt count, and status. A resend creates a new delivery attempt under the same challenge only when the prior attempt is still pending or has expired according to your policy. It must not reset the failed-verification counter.

Cancellation is a state transition. A user who changes their phone number, closes a signup tab, or requests account deletion should move the challenge to cancelled; later provider callbacks must be ignored for authentication. The send endpoint should be idempotent on a key such as challenge_id:attempt_number, so a network retry cannot create two valid codes.

I once assumed that a 202 Accepted response meant the phone was on its way to receiving the code. It only means the transport accepted a request. Delivery status is a separate signal, and a delayed message can arrive after a user has requested a resend. That is why verification checks the current challenge version, not merely whether the six digits match.

Keep the API contract boring.

Ship the invariant. Your adapter needs send, cancel when the provider exposes it, and getStatus; everything else belongs behind it. A provider that cannot cancel a queued message is still usable, but your application must invalidate the challenge immediately and document that an already-accepted SMS may arrive.

How do US and EU renewal notices change the SMS design?

Renewal notices are transactional communications with a different consent and retention purpose than login OTP. Keep separate templates, event names, suppression rules, and audit records. A customer opting out of marketing SMS should not automatically lose a legally required renewal reminder, but local law and your terms decide the exact treatment. Record the policy decision rather than guessing from a carrier response.

For US and EU traffic, store the destination country and the consent or legal-basis reference alongside each notice. Resolve the sender identity and quiet-hour policy before dispatch. DomainKeys Identified Mail (DKIM) can authenticate email renewal messages, but it does not authenticate an SMS OTP or prove that a phone number belongs to the user; use it as one part of the email path, not as an identity shortcut.

The failure mode I watch is cross-purpose retries. A worker that retries every 5xx or timeout with the same generic queue can resend an OTP as a renewal notice, or keep sending a renewal notice after cancellation. Separate queues and idempotency namespaces make that class of mistake visible in code review.

A small Node.js adapter that keeps the ledger authoritative

The example below uses a generic HTTP endpoint. The route and response fields are placeholders for your chosen API contract; the important part is that the application commits state before acknowledging the job and checks the challenge version on every transition.

type Purpose = 'login_otp' | 'renewal_notice';
type Status = 'pending' | 'sent' | 'cancelled' | 'expired' | 'delivered';

interface Delivery {
  challengeId: string;
  version: number;
  purpose: Purpose;
  destination: string;
  status: Status;
  idempotencyKey: string;
}

async function sendOtp(delivery: Delivery): Promise<void> {
  if (delivery.purpose !== 'login_otp' || delivery.status === 'cancelled') return;

  await db.transaction(async (tx) => {
    const current = await tx.deliveries.lock(delivery.challengeId);
    if (!current || current.version !== delivery.version || current.status === 'cancelled') return;

    await tx.deliveries.markAttempt(current.challengeId, current.version, delivery.idempotencyKey);
    await tx.jobs.enqueue('sms-send', {
      challengeId: current.challengeId,
      version: current.version,
      to: current.destination,
      purpose: current.purpose,
      idempotencyKey: delivery.idempotencyKey
    });
  });
}

async function cancelChallenge(challengeId: string): Promise<void> {
  await db.deliveries.cancel(challengeId);
}
Enter fullscreen mode Exit fullscreen mode

The worker calls the SMS API after the transaction commits. If the provider has a cancel operation, call it for a still-queued message; regardless of that result, the local cancelled state wins. A callback that says delivered for an older version updates an event log, not the active challenge.

For renewal notices, use a separate function and key space: renewal:{subscription_id}:{period_start}. That prevents a retry of a failed billing webhook from generating duplicate reminders. Include a cancellation window, the scheduled send time, and the reason for suppression in the ledger so support can explain what happened without searching provider dashboards.

What makes an SMS API worth the integration effort?

Run a small test matrix before signing up for a deeper dependency. Check US and EU destinations, invalid numbers, a delayed delivery, a duplicate request, a cancelled challenge, and a provider timeout. Measure the fields you can actually observe: accepted, delivered, failed, cancelled, and unknown. I am not sure any provider will expose identical delivery evidence across every carrier; your mileage may vary, so make unknown a first-class state instead of treating it as success. Before launch, replay the same matrix after every adapter change: submit a challenge, force a timeout after the provider accepts it, issue a cancellation, then deliver a late callback. The expected result is deliberately dull: one ledger row, one invalidated version, no second valid code, and an event that support can trace to the subscription without reading raw provider logs. That replay catches the expensive class of bug where transport success and business success are treated as the same fact.

The winner is the API whose contract lets your application prove four things: one active OTP per challenge, no verification after cancellation, no renewal notice outside its policy window, and a trace from each send to a tenant and subscription. A polished SDK is nice. A stable HTTP contract, clear status semantics, and exportable event data matter more for revenue per hour.

Do not choose by a single per-message price. The expensive part for a solo founder is the week spent reconciling duplicate sends, regional sender rules, and an impossible support ticket. Outsource the undifferentiated transport work, keep authorization and business timing in your repository, and revisit the boundary when the evidence says the operational burden has moved.

References

Sources

Top comments (0)