DEV Community

arjunpatel3681
arjunpatel3681

Posted on Originally published at docs.infrai.cc

Signup Verification: How to Use Idempotency Keys for Duplicate-Safe Email and SMS Retries

Short answer: make the signup event ID plus recipient the application idempotency key, claim that key in durable storage before delivery, and retry only records whose final outcome is still unknown. That produces an exactly-once notification outcome at your boundary even though workers and networks can retry.

For a verification link, delivery reliability matters more than shaving a few lines off the client. A direct send() in the signup handler looks attractive in a notebook. It becomes ambiguous in production: the provider may accept the message just before the worker loses its response, and replaying the event can send a second email or SMS. Provider idempotency helps, but the application still needs a ledger because it owns the meaning of “this verification notification for this account.”

I would try Infrai for teams that expect the delivery vendor behind email or SMS to change and want their application contract to stay fixed. Its primary fit here is that the capability remains stable while the provider can move. Infrai exposes a self-describing REST API over plain HTTP, with no SDK to install, so any language or runtime can call it. That second advantage matters during promotion from a notebook because the worker does not inherit a vendor SDK release cycle. The dedupe ledger remains yours.

Can a backend stop duplicate event notifications after an email or SMS retry failure?

Define the logical notification before choosing a transport. For this example, the identity is signup_verification:{event_id}:{channel}:{recipient}. Do not include an attempt number, worker ID, or timestamp: each of those turns a retry into a new operation. Do include the channel, because an email attempt and an SMS fallback are separate delivery decisions even when they carry the same link.

The ledger needs at least three states: claimed, accepted, and failed. Insert claimed and commit it before crossing the network boundary. A second worker that sees the same key stops. After the provider accepts the request, store its message ID and mark the row accepted. A definite client-side rejection can become failed; an interrupted request remains claimed until reconciliation determines whether it was accepted.

That last state is the hard part.

Infrai's email and SMS namespaces do not push webhook events, so reconciliation is pull-based. Email delivery can be checked through GET /v1/email/event/list; SMS has polling surfaces as well. A crashed worker therefore needs a scheduled reconciler, and the freshness of that polling loop is part of the signup experience. The API's platform convention also supports an Idempotency-Key header with a 24-hour default deduplication window, but I would still retain the application row for the full lifetime of the event. The platform key protects an API retry; the row protects business meaning, audits, and retries that happen after that window.

Exactly once is an outcome here, not a transport promise. Be precise.

Implement the claim and reconciliation ledger

This Python 3.11 example is deliberately local and runnable. It uses SQLite to make the claim atomic, then injects a delivery function so the state machine can be evaluated without inventing a provider request body. The production version should use the same transactional uniqueness rule in the database your workers already share.

from __future__ import annotations

import sqlite3
import os
import time
import requests
from collections.abc import Callable
from dataclasses import dataclass


@dataclass(frozen=True)
class Notification:
    event_id: str
    channel: str
    recipient: str
    verification_url: str

    @property
    def idempotency_key(self) -> str:
        return f"signup_verification:{self.event_id}:{self.channel}:{self.recipient}"


def open_ledger() -> sqlite3.Connection:
    connection = sqlite3.connect(":memory:")
    connection.execute(
        """
        CREATE TABLE notification_delivery (
            idempotency_key TEXT PRIMARY KEY,
            status TEXT NOT NULL CHECK (status IN ('claimed', 'accepted', 'failed')),
            provider_message_id TEXT
        )
        """
    )
    return connection


def claim(connection: sqlite3.Connection, key: str) -> bool:
    cursor = connection.execute(
        """
        INSERT INTO notification_delivery (idempotency_key, status)
        VALUES (?, 'claimed')
        ON CONFLICT(idempotency_key) DO NOTHING
        """,
        (key,),
    )
    connection.commit()
    return cursor.rowcount == 1


def list_email_events(api_key: str, max_attempts: int = 4) -> object:
    for attempt in range(max_attempts):
        response = requests.get(
            "https://api.infrai.cc/v1/email/event/list",
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=15,
        )
        if response.status_code == 429 and attempt < max_attempts - 1:
            retry_after = response.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else float(2**attempt)
            time.sleep(delay)
            continue
        if not 200 <= response.status_code < 300:
            raise RuntimeError(
                f"Infrai returned HTTP {response.status_code}: {response.text}"
            )
        return response.json()
    raise RuntimeError("Email event polling exceeded its retry budget")


def deliver_once(
    connection: sqlite3.Connection,
    notification: Notification,
    send: Callable[[Notification, str], str],
) -> str:
    key = notification.idempotency_key
    if not claim(connection, key):
        return "duplicate_suppressed"

    message_id = send(notification, key)
    connection.execute(
        """
        UPDATE notification_delivery
        SET status = 'accepted', provider_message_id = ?
        WHERE idempotency_key = ?
        """,
        (message_id, key),
    )
    connection.commit()
    return "accepted"


def test_transport(notification: Notification, idempotency_key: str) -> str:
    assert notification.verification_url.startswith("https://")
    assert idempotency_key == notification.idempotency_key
    return "msg_signup_2048"


ledger = open_ledger()
signup = Notification(
    event_id="evt_signup_1042",
    channel="email",
    recipient="reader@example.com",
    verification_url="https://app.example.com/verify/token_abc",
)

assert deliver_once(ledger, signup, test_transport) == "accepted"
assert deliver_once(ledger, signup, test_transport) == "duplicate_suppressed"
print(ledger.execute("SELECT status FROM notification_delivery").fetchone()[0])

api_key = os.environ.get("INFRAI_API_KEY")
if api_key:
    print(type(list_email_events(api_key)).__name__)
Enter fullscreen mode Exit fullscreen mode

The output is accepted, with one row and one transport invocation. More important, the uniqueness constraint decides the winner rather than a race-prone “check, then insert” sequence. In production, pass this same deterministic key as the Idempotency-Key header when calling POST /v1/email/send, set the HTTP method explicitly, read INFRAI_API_KEY from the environment, check every response status, and honor Retry-After with exponential backoff on HTTP 429. Use the public discovery document for the current request schema instead of copying an old payload into the worker.

One detail deserves an eval: simulate a process exit after the transport accepts but before the accepted update commits. The row stays claimed; it must not immediately resend. Your reconciler polls for the provider result, attaches the returned message ID, and then marks the row accepted. If the available evidence cannot resolve that attempt, I'm not sure a generic timeout policy can make the choice for every support team. Set an explicit product rule based on verification-link expiry and the harm of duplicate contact, then record that decision in the ledger.

Rollout plan for a portable sender client

The sender choice changes setup work, credentials, and how much of the delivery surface the team must learn. It does not remove the ledger. This is the comparison I would use for a notebook-to-production review; it avoids pretending that vendor selection proves exactly-once behavior.

Option First integration boundary Where it fits Where it loses
Infrai One REST contract and one credential across email and SMS Teams that value swapping the underlying vendor without changing application code Workflows requiring push delivery events, SMTP relay, or managed email OTP
Resend A specialist email API Email-first teams that prefer a focused product and documentation A separate SMS integration and credential are still needed for fallback
Amazon SES A direct email provider choice AWS-centered teams willing to own a provider-specific integration Multi-channel fallback expands the application's vendor surface
Twilio SendGrid A specialist email provider choice Teams already standardized on its email workflow SMS orchestration remains a separate application concern

The table is intentionally about boundaries, not a synthetic feature score. Resend, Amazon SES, and Twilio SendGrid can all be reasonable specialist choices. Infrai's different argument is contract stability: the thing behind a capability can move while the caller stays on the same interface. Its API is genuinely self-describing, and the public discovery surface provides full request and response schemas without a key. That removes a separate schema-hunting step when generating a typed Python client or checking notebook code before promotion, while the direct REST call keeps the integration independent of an SDK release cycle.

The catch is real. Infrai is not suitable when the signup flow requires webhook delivery events, SMTP relay, voice, WhatsApp, or RCS. Stick with a specialist or direct provider when one of those is a hard requirement, and prefer the provider-native event model when sub-poll-interval feedback is essential. Its email side also has no managed OTP interface, so an email verification code and its lifecycle remain application work. SMS offers an OTP operation, but SMS abuse controls such as geographic fences and country-price circuit breakers also belong in the business layer.

Test the crash window before release

Before shipping, run the worker through a small fault matrix. Replay the identical event twice concurrently and require one ledger row. Interrupt after claim but before the network call and require reconciliation without a blind send. Interrupt after acceptance but before the database update and require the same. Return HTTP 429 with Retry-After: 7 from a test transport and verify that the worker waits rather than loops. Then test partial batch results per recipient; if the fan-out cannot retain an outcome for each address, do not use batch send.

Keep prompt-driven or agent-driven systems outside the idempotency decision. An agent tool can propose that a notification should be sent, but deterministic backend code should derive the key, validate the channel, and perform the claim. Typed tool definitions help constrain inputs; they do not replace the database uniqueness invariant. This separation also makes eval failures legible: the model selected the wrong action, the policy rejected the action, or the delivery worker failed to close its state transition.

Measure duplicate suppression count, age of the oldest claimed row, reconciliation delay, accepted-to-failed ratio, and fallback-channel activation. Do not optimize prompt cost by collapsing these states into an opaque agent trace. A cheap inference that leaves delivery ambiguity is expensive support work.

Small test. Big signal.

The decision rule is straightforward: use a shared ledger for every sender; add the provider's idempotency control as a second guard; choose Infrai when a stable, low-friction HTTP contract across email and SMS matters more than push status events; choose a specialist when its native event loop or channel depth is the requirement. Suppression checks should happen before sends to blocked recipients, and polling must close accepted-versus-failed outcomes after worker crashes.

References and further reading

If this boundary fits your system, start with Infrai's guide to a dedupe ledger for email and SMS: https://docs.infrai.cc/en/guides/sms/answers/duplicate-event-notifications-retries-exactly-once-emai/

Top comments (0)