A signup that needs email verification is a small distributed transaction. Your code creates an address, a third-party site sends a message to it on its own schedule, and something has to notice that message and act on it before the code expires. If the worker restarts in the middle, a plain script loses its place: it forgets which inbox it created and which step it was on. Temporal keeps that place in workflow history, so the job becomes deciding which parts are activities, how each one retries, and what happens to the inbox afterwards.

This post uses the Temporal TypeScript SDK (1.x) and the Lumbox REST API through fetch.

Activities: one HTTP call each

// activities.ts
import { ApplicationFailure } from "@temporalio/activity";

const BASE = "https://api.lumbox.co/v1";

async function lumbox(path: string, init: { method?: string; body?: unknown; idempotencyKey?: string } = {}) {
  const res = await fetch(BASE + path, {
    method: init.method ?? "GET",
    headers: {
      "X-API-Key": process.env.LUMBOX_API_KEY!,
      "Content-Type": "application/json",
      ...(init.idempotencyKey ? { "Idempotency-Key": init.idempotencyKey } : {}),
    },
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
  });
  const body = await res.json().catch(() => null);
  if (res.ok) return body;
  const message = body?.error ?? `HTTP ${res.status}`;
  if ([400, 401, 402, 403, 404].includes(res.status)) {
    throw ApplicationFailure.nonRetryable(message, `Lumbox${res.status}`);
  }
  throw ApplicationFailure.retryable(message, `Lumbox${res.status}`);
}

export async function createInbox(idempotencyKey: string) {
  return lumbox("/inboxes", { method: "POST", body: {}, idempotencyKey }) as Promise<{ id: string; address: string }>;
}

export async function waitForOtp(inboxId: string, since: string) {
  const qs = new URLSearchParams({ since, timeout: "60" });
  return lumbox(`/inboxes/${inboxId}/otp?${qs}`) as Promise<{ code: string; expires: string | null }>;
}

export async function findVerificationLink(inboxId: string, since: string) {
  const qs = new URLSearchParams({ since, category: "verification", limit: "5" });
  const page = await lumbox(`/inboxes/${inboxId}/emails?${qs}`);
  return (page.data[0]?.parsed.verification_links[0] ?? null) as string | null;
}

export async function deleteInbox(inboxId: string) {
  try {
    await lumbox(`/inboxes/${inboxId}`, { method: "DELETE" });
  } catch (err) {
    if (!(err instanceof ApplicationFailure && err.type === "Lumbox404")) throw err;
  }
}

// submitSignup, clickResend and enterCode drive the third-party site and
// return an ISO timestamp taken just before they act.

The helper sorts Lumbox responses into two groups. A 401 (bad key), 402 (PLAN_LIMIT_EXCEEDED), 403 or 404 will give the same answer on every attempt, so it throws ApplicationFailure.nonRetryable and Temporal stops. Everything else, including 429 rate limits, 5xx, and the 408 that the OTP long-poll returns when no code arrived in time, is thrown as retryable and left to the retry policy.

createInbox takes an idempotency key from the workflow. Temporal retries an activity whose result it never recorded, which includes the case where the inbox was created and the worker died before reporting back. Lumbox stores an Idempotency-Key for 24 hours and replays the original response for a repeat request with the same key and body, so the retry gets the same inbox instead of a second one.

If you use the lumbox npm package inside activities instead of fetch, construct it with maxRetries: 0. The SDK retries 408s, 429s and 5xx on its own by default, and two retry layers stacked on each other make timeouts hard to reason about.

The workflow

// workflows.ts
import { ActivityFailure, ApplicationFailure, proxyActivities, workflowInfo } from "@temporalio/workflow";
import type * as activities from "./activities";

const { createInbox, submitSignup, clickResend, enterCode, deleteInbox } =
  proxyActivities<typeof activities>({
    startToCloseTimeout: "2 minutes",
    retry: { maximumAttempts: 5 },
  });

const { waitForOtp } = proxyActivities<typeof activities>({
  startToCloseTimeout: "90 seconds",
  retry: { initialInterval: "5 seconds", backoffCoefficient: 1, maximumAttempts: 5 },
});

export async function signupWorkflow(service: string): Promise<string> {
  const inbox = await createInbox(`inbox-${workflowInfo().workflowId}`);
  try {
    let since = await submitSignup(service, inbox.address);
    for (let round = 0; round < 3; round++) {
      try {
        const otp = await waitForOtp(inbox.id, since);
        await enterCode(service, otp.code);
        return inbox.address;
      } catch (err) {
        if (!(err instanceof ActivityFailure)) throw err;
        since = await clickResend(service);
      }
    }
    throw ApplicationFailure.nonRetryable("No verification code after 3 rounds", "NoCode");
  } finally {
    await deleteInbox(inbox.id);
  }
}

Each waitForOtp attempt is one long-poll. The server holds the request for up to 60 seconds and returns the code as soon as a matching email arrives. When it answers 408, Temporal waits five seconds and polls again, up to five attempts, so one round listens for a little over five minutes. After that the activity fails, the workflow clicks "resend", and the next round starts with a new since. Keeping each attempt short means the activity never runs long enough to need heartbeats. If you would rather loop inside a single activity, call heartbeat() between polls and set heartbeatTimeout, so a dead worker is noticed within that window instead of at the end of startToCloseTimeout.

The since value is what makes the resend loop correct. The OTP endpoint returns the newest verification email received after since, and without the parameter it looks back five minutes. In round two, the email from round one is still inside that five-minute window; anchoring each round to the time of its own request means the old code is never returned as the new one. The timestamp comes from the activity that submitted the form, not from the workflow, which keeps wall-clock reads out of workflow code.

Waits measured in hours

Some services send a confirmation link that stays valid for a day, and some send it late. A workflow can wait for those without holding anything open, because sleep in a workflow is a durable timer recorded in history. The worker can be restarted, redeployed or scaled to zero in between.

import { sleep } from "@temporalio/workflow";

let link: string | null = null;
for (let check = 0; check < 24 && !link; check++) {
  link = await findVerificationLink(inbox.id, since);
  if (!link) await sleep("1 hour");
}

This works because Lumbox inboxes do not expire. There is no TTL on an inbox; it and its emails stay until you call DELETE /v1/inboxes/:id, so a link that arrived at 3 a.m. is still there when the timer fires at 4. The flip side is that nothing cleans up for you, which is why the workflow above deletes the inbox in finally, and why a workflow that fails before reaching it should still run a cleanup step. Each open inbox counts toward your plan's limit, and the Free plan allows three (pricing).

Delete only when the address is disposable. If the agent is creating an account it will keep using, that address is now the account's login and password-reset address; deleting the inbox removes the mail already in it and frees the address for someone else to claim. Skip the finally in that case and record the inbox ID with the account.

What the long-poll does not give you

/otp only returns numeric codes (4 to 8 digits near words like "code" or "verification") from emails categorised as verification mail. Alphanumeric codes and link-only emails need findVerificationLink or a read of the email text. And every wait costs an open HTTP request per attempt; with many workflows waiting at once, the per-key limit of 120 requests per minute applies. If you run hundreds of concurrent signups, a webhook that signals the workflow is cheaper than polling, which is the pattern in the Inngest post.

The inbox docs list the wait and OTP parameters, and long-polling for agents covers how the wait endpoints behave under load.