Inngest runs functions in response to events, with each step.run retried and memoized on its own, and with step.sleep and step.waitForEvent for pauses that last minutes or days. Lumbox can produce an event every time mail reaches one of your inboxes. Connecting the two gives you email-driven functions without a queue of your own, but the join has a few specifics: the payload shape, signature verification, a race in waitForEvent, and the fact that Lumbox does not redeliver a webhook that failed.

The code uses the Inngest TypeScript SDK v4, where triggers go in the function's options object ({ id, triggers: { event } }) rather than as a separate second argument as in v3.

Getting Lumbox events into Inngest

A Lumbox webhook delivery is a POST with the body {"event": "email.received", "timestamp": "...", "data": {...}}. Inngest events are shaped { name, data }, so the connector is an Inngest webhook URL with a transform function, created in the Inngest dashboard:

function transform(evt, headers, queryParams, raw) {
  return {
    name: `lumbox/${evt.event}`,
    data: {
      inbox_id: evt.data.inbox_id,
      email_id: evt.data.email_id,
      category: evt.data.category,
      raw,
      sig: headers["X-Lumbox-Signature"],
    },
  };
}

Then register that URL with Lumbox:

curl -X POST https://api.lumbox.co/v1/webhooks \
  -H "X-API-Key: $LUMBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://YOUR-INNGEST-WEBHOOK-URL", "events": ["email.received"]}'

The response includes a secret starting with whsec_, shown only once. Lumbox signs each delivery with HMAC-SHA256 over the exact JSON body using that full secret as the key, and sends the hex digest in X-Lumbox-Signature. The transform passes the raw body and the signature through untouched so the function can check them. Webhooks are per org, so this one URL receives mail for every inbox you have, and the inbox_id field is how functions tell them apart.

Handling received mail

import { createHmac, timingSafeEqual } from "node:crypto";
import { Inngest, NonRetriableError } from "inngest";

export const inngest = new Inngest({ id: "email-agent" });

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

export 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),
  });
  if (res.status === 408) return null;
  if (!res.ok) throw new Error(`Lumbox ${res.status}: ${await res.text()}`);
  return res.json();
}

function signed(raw: string, sig: string | undefined) {
  const expected = createHmac("sha256", process.env.LUMBOX_WEBHOOK_SECRET!).update(raw).digest("hex");
  return !!sig && sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

export const onEmail = inngest.createFunction(
  { id: "lumbox-email-received", triggers: { event: "lumbox/email.received" } },
  async ({ event, step }) => {
    if (!signed(event.data.raw, event.data.sig)) throw new NonRetriableError("bad signature");
    if (event.data.category === "verification") return; // handled by the signup function

    const email = await step.run("fetch-email", () => lumbox(`/emails/${event.data.email_id}`));
    await step.run("triage", () => triage(email)); // your logic
  },
);

A failed signature check throws NonRetriableError, because retrying a forged or corrupted event cannot fix it. The event does not contain the body (the webhook carries IDs, sender, subject, category, extracted codes and spam fields), so the first step fetches the full email from GET /v1/emails/:id. That step's result is memoized: if triage fails and retries, Inngest does not fetch the email again.

A signup that waits for its code

step.waitForEvent pauses a function until a matching event arrives or a timeout passes, and returns null on timeout. It is the natural fit for "submit the form, then wait for the verification email", with one catch that the Inngest docs state directly: a wait only matches events sent after the wait starts. If the email arrives between the form submit and the start of the wait, the function sleeps through it.

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

export const signup = inngest.createFunction(
  { id: "signup-with-inbox", triggers: { event: "app/signup.requested" } },
  async ({ event, step }) => {
    const inbox = await step.run("create-inbox", () =>
      lumbox("/inboxes", { method: "POST", body: {}, idempotencyKey: `inbox-${event.data.signupId}` }),
    );
    const since = await step.run("submit-form", () => submitSignup(event.data.service, inbox.address));

    let otp = await step.run("check-early", () => readCode(inbox.id, since));
    if (!otp) {
      await step.waitForEvent("wait-for-code-email", {
        event: "lumbox/email.received",
        timeout: "10m",
        if: `async.data.inbox_id == "${inbox.id}" && async.data.category == "verification"`,
      });
      otp = await step.run("read-code", () => readCode(inbox.id, since));
    }
    if (!otp) throw new NonRetriableError("no verification code");

    await step.run("enter-code", () => enterCode(event.data.service, otp.code));
    await step.run("delete-inbox", () => lumbox(`/inboxes/${inbox.id}`, { method: "DELETE" }));
  },
);

The function treats the Lumbox event as a wake-up signal and reads the facts from the API. That handles three cases with one code path. If the email arrived before the wait began, check-early finds it. If the event arrives during the wait, read-code finds it. If the event never arrives because the webhook delivery failed, the wait times out after ten minutes and read-code still finds an email that is sitting in the inbox. It also means a forged event matching the if expression can at most cause an extra API read, since the code always comes from Lumbox, not from the event.

readCode passes since, the timestamp submitSignup returned from just before it submitted. The OTP endpoint returns the newest verification email received after that time; without the parameter it looks back five minutes, which on a retried signup can hand back an older code. A 408 from the short five-second poll means "nothing yet" and comes back as null.

The idempotency key on create-inbox covers the one gap step memoization leaves: a step that created the inbox but failed before Inngest recorded its result. Lumbox replays the stored response for a repeated key and body within 24 hours, so the retry gets the same inbox.

Scheduled follow-ups

export const followUp = inngest.createFunction(
  { id: "follow-up", triggers: { event: "app/signup.completed" } },
  async ({ event, step }) => {
    await step.sleep("wait-3-days", "3d");
    await step.run("send-follow-up", () =>
      lumbox(`/inboxes/${process.env.AGENT_INBOX_ID}/send`, {
        method: "POST",
        idempotencyKey: `followup-${event.data.userId}`,
        body: { to: event.data.email, subject: "How is it going?", text: "Checking in after your first week." },
      }),
    );
  },
);

The send goes through fetch with an Idempotency-Key, so if the step retries after the request reached Lumbox, the second request returns the stored response instead of sending a second email. Sends are rate limited per minute by plan and capped per month, and 429 and 402 responses come back as errors that Inngest will retry; a 402 will not succeed on retry, so check for it and throw NonRetriableError if you send in volume.

What to watch

Lumbox does not retry a webhook delivery for received mail today, and it waits 10 seconds for a response. The Inngest webhook endpoint answers quickly, which helps, but any design here should survive a missing event, as the signup function does.

The webhook also fires for mail the spam scorer flagged. The Lumbox payload carries is_spam and spam_score; add them to the transform's data and return early in onEmail if you do not want an agent triaging spam. And the signup function deletes its inbox only on the success path. When it throws, the inbox stays and keeps counting toward your plan's inbox limit (three on Free), so give the function an onFailure handler that deletes it, or run a periodic cleanup.

For the general tradeoff, see webhooks vs polling; the Temporal version of this flow polls instead of waiting for events. Webhook fields are listed in the webhooks docs.