DEV Community

MirageB18
MirageB18

Posted on

Custom Password Reset API Selection for Auth Systems Without Webhooks

The most important trade-off is evidence versus coupling: for password recovery and settled-order receipts, choose an email API that lets your application record a durable submission result and later reconcile delivery events without making either workflow depend on a provider-specific webhook. Short answer: put an outbox between the business event and the email API, assign your own stable message key, store the remote message identifier, and poll a bounded set of unresolved messages. A polished template matters less than proving what the system tried to send, when it tried, and what the provider reported afterward.

This decision applies differently to the two messages. A password-reset email carries a short-lived capability, so stale retries can create confusing or unsafe behavior. An order receipt records a settled payment, so losing the send request leaves a gap in the marketplace's evidence trail. One transport contract can support both, but the retry policy must remain attached to the message purpose.

What must the application be able to prove?

Start with three records: the event that authorized the message, the exact outbound request, and the latest provider observation. For a receipt, the authorizing event is the payment-settled event, including its immutable payment reference. For password recovery, it is the reset request and the expiration attached to its token. Do not put the raw reset token in logs or reconciliation tables.

The email service's acceptance response is not proof that a recipient received or read a message. It proves only what the response actually says. Preserve the status code, response time, provider message ID, and a hash or version of the rendered content. Later delivery, delay, bounce, or complaint observations belong in an append-only event history so a new poll cannot erase an earlier state.

Keep that boundary.

That distinction shapes API selection. The useful question is not "Does this service send email?" It is "Can I connect each accepted request and each later event to one business event without guessing?" An API that exposes a stable message identifier and queryable message events fits a polling design. If events can only be pushed, or can only be searched manually in a dashboard, the compliance record remains incomplete when webhooks are unavailable.

Put the evidence boundary in code first

The data flow is small. Payment settlement or an auth request writes an outbox row in the same transactional boundary as the application state change. A worker claims the row, renders a versioned template, submits it through a narrow transport interface, and stores the result. A separate reconciler polls only submitted rows whose outcome is still open. Business code never needs to know the remote API's JSON shape.

Here is the contract I would ship before evaluating any provider SDK. It deliberately models evidence, not marketing features.

type MessagePurpose = "password_reset" | "order_receipt";
type DeliveryState = "submitted" | "delivered" | "delayed" | "bounced" | "unknown";

interface OutboundMessage {
  messageKey: string;
  purpose: MessagePurpose;
  recipient: string;
  templateVersion: string;
  authorizedAt: string;
  expiresAt?: string;
  data: Record<string, string>;
}

interface SubmissionEvidence {
  messageKey: string;
  remoteMessageId: string;
  acceptedAt: string;
  requestDigest: string;
}

interface DeliveryObservation {
  remoteMessageId: string;
  state: DeliveryState;
  observedAt: string;
  remoteEventId?: string;
  diagnosticCode?: string;
}

interface EmailTransport {
  submit(message: OutboundMessage): Promise<SubmissionEvidence>;
  inspect(remoteMessageId: string): Promise<DeliveryObservation[]>;
}
Enter fullscreen mode Exit fullscreen mode

The worker must decide whether an uncertain submission can be repeated. A network timeout is ambiguous: the remote service may have accepted the message even though the client never received the response. Use the application messageKey as an idempotency key when the API supports that behavior. When it does not, mark the row unknown and reconcile before resubmitting. Blind retry loops are especially bad for reset messages because several valid-looking emails can arrive out of order.

The following worker keeps those states explicit. The endpoint names are placeholders for an adapter, not claims about any commercial API.

async function submitMessage(
  message: OutboundMessage,
  fetchFn: typeof fetch,
): Promise<SubmissionEvidence> {
  if (message.expiresAt && Date.parse(message.expiresAt) <= Date.now()) {
    throw new Error("Refusing to send an expired message");
  }

  const response = await fetchFn("https://mail-gateway.example/messages", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "idempotency-key": message.messageKey,
    },
    body: JSON.stringify(message),
    signal: AbortSignal.timeout(8_000),
  });

  if (!response.ok) {
    throw new Error(`Submission failed with status ${response.status}`);
  }

  const body = (await response.json()) as {
    id: string;
    acceptedAt: string;
    requestDigest: string;
  };

  return {
    messageKey: message.messageKey,
    remoteMessageId: body.id,
    acceptedAt: body.acceptedAt,
    requestDigest: body.requestDigest,
  };
}
Enter fullscreen mode Exit fullscreen mode

An eight-second client deadline is an application choice in this example, not a universal recommendation. Set it from your worker's execution budget and measured network behavior. The important part is classifying a timeout as uncertain rather than pretending it is a confirmed rejection. The Fetch API rejects on some request failures, but an HTTP error status such as 404 does not itself reject the promise; code must check Response.ok or Response.status.

How should custom auth systems poll password reset events?

Poll from your own unresolved-message index, never by scanning an entire remote account. Each row should have nextCheckAt, attemptCount, and a terminal-state marker. Claim a limited batch, ask for events by the stored remote ID, append unseen events using the remote event ID when available, then schedule the next check with bounded backoff.

Fast polling forever wastes requests and does not improve old evidence. A practical schedule checks recent submissions more often, then backs off while the provider may still report a delayed outcome. Stop only under an explicit rule: a terminal event arrived, the documented event-retention window passed, or a locally defined evidence deadline expired. Store which rule closed the row. Silence is unknown, not delivered.

Two loops. One record.

Keep time semantics precise. authorizedAt belongs to the business event, acceptedAt comes from submission evidence, and observedAt records when an event was seen. If the remote event contains its own occurrence time, preserve that separately. Replacing these with one updatedAt timestamp makes an audit impossible to reconstruct.

Polling also needs a budget. Cap concurrency, add jitter to backoff, and honor retry instructions supplied by the HTTP response. On authentication failures, pause the worker and alert rather than consuming the attempt budget for every row. On rate limiting or transient server responses, reschedule. On a confirmed malformed request, quarantine the row with its template version and validation error.

Short-lived reset links need one extra guard: the worker checks expiry before first submission and before any retry. Receipts do not expire in the same way, but they should be deduplicated against the immutable settlement reference. Same transport, different rule.

Selection criteria that survive a provider change

I would run a small conformance test against each candidate API and keep the results beside the adapter. The test sends a non-sensitive fixture, forces a client timeout after submission, queries by the returned message ID, and verifies that repeated polling does not duplicate local events. It also checks what happens after an invalid recipient and after credentials are revoked. These cases reveal far more than a feature matrix.

The decisive fields are straightforward:

Capability Why it matters Reject or compensate when absent
Client-supplied idempotency key Resolves ambiguous retries Reconcile before resubmission
Stable remote message ID Joins submission to later evidence Reject if events cannot be correlated another way
Event lookup API Enables operation without webhooks Reject for this architecture
Documented event retention Defines the reconciliation deadline Export events sooner and record the cutoff
Machine-readable bounce details Supports suppression and investigation Preserve the raw documented category
Predictable authentication scopes Limits worker credentials Isolate the adapter credential

Do not award points for a large SDK. A thin adapter over fetch can be easier to audit, keeps retries under application control, and reduces lock-in. An SDK is still reasonable when it exposes raw identifiers, response statuses, cancellation, and error details without hiding them. Test those properties; do not infer them from fluent method names.

Cost belongs in the decision, but it is not the headline. Model outbound submissions, status reads, retention exports, and operational labor. Polling can turn one email into several API reads, so a nominal send price does not describe the workload. Measure the read multiplier with your chosen schedule and expected time to terminal events.

Failure handling is part of the compliance record

There are two retry loops, and mixing them creates duplicate messages. Submission retries answer, "Was the request accepted?" Reconciliation retries answer, "Has the accepted message reached a later state?" Give them separate counters and alerts. Consider the awkward timeout path in full: the worker sends a receipt, the connection closes before a response arrives, and the outbox still lacks a remote ID. An immediate retry might produce a second receipt; declaring failure might hide an accepted first request. The row therefore stays in an uncertain state, the adapter first attempts lookup by the application key if the candidate API documents such a lookup, and an operator-visible deadline controls any later resubmission. This costs more state and leaves some cases unresolved for longer, but it preserves the difference between evidence and assumption.

For a marketplace receipt, persist the currency, amount in minor units, order ID, settlement reference, recipient, and template version used to render the message. Keep sensitive payment credentials out of the email record. For password recovery, persist the request ID, recipient, template version, authorization time, and token expiry, but never the reset token itself. Access to both evidence stores should be limited and logged according to the application's own compliance requirements.

Templates deserve deployment discipline too. Render fixtures in CI, verify that receipt totals and recovery expiration text are present, and deploy template versions before producers can reference them. Then a rollback changes the producer's selected version; it does not mutate evidence for messages already sent.

Observability should follow the same boundaries. Track outbox age, submission outcomes, unknown submissions, reconciliation lag, and terminal delivery categories. Alert on a growing oldest-row age or a sudden rise in unknown outcomes. A delivery-rate chart alone can look healthy while one partition of the outbox is stuck.

Ship the smallest auditable loop

Before launch, trace one settled order and one password-reset request from authorization through the outbox, submission evidence, polling history, and terminal state. Then simulate an HTTP error response, a timeout with ambiguous acceptance, an expired reset token, an invalid recipient, a duplicate poll result, and exhausted event retention. Confirm that operators can distinguish every case without opening a provider dashboard.

Keep the first production loop narrow: one worker for submission, one reconciler, one append-only observation table, and one adapter. Review retention and access controls with whoever owns compliance, because an email event log can contain personal data even when message bodies are excluded. Finally, document the rule for closing an unknown outcome. The provider decision is defensible when the evidence chain remains understandable after the adapter is replaced.

Sources

Top comments (0)