LumboxLumbox Docs

Skyvern

Connect Skyvern's TOTP pull callback to a Lumbox inbox.

Skyvern

Use Skyvern's totp_url pull contract to serve email verification codes from a Lumbox inbox. This example creates an inbox, starts a signed callback server, and runs one Skyvern task. The callback waits for at most one second per request so Skyvern can poll again while the email arrives.

By Kumar

Install and configure

Use Node.js 20 or newer. The example was checked with @skyvern/client 1.0.55.

npm install @skyvern/client@1.0.55 tsx@4.23.15 typescript@7.0.2

Set these environment variables. SIGNUP_URL must be a signup page you control or are authorized to automate. Its instructions must explain how to submit the email address and what success looks like. EXPECTED_SENDER is a substring used to narrow Lumbox's sender filter. This is a convenience filter, not sender authentication.

export LUMBOX_API_KEY=ak_your_key
export SKYVERN_API_KEY=your_skyvern_api_key
export PUBLIC_HTTPS_BASE_URL=https://your-public-host.example
export SIGNUP_URL=https://your-site.example/signup
export EXPECTED_SENDER=accounts@your-site.example
export SIGNUP_INSTRUCTIONS='Enter the email address in the signup form, submit it, enter the emailed verification code, and confirm the account dashboard is visible.'
export PORT=8787

The HTTPS base URL must forward to this process. This guide does not create or deploy a tunnel. Run the example as npx tsx skyvern.ts.

Run the signup

import { createHmac, timingSafeEqual, randomBytes } from "node:crypto";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { Skyvern } from "@skyvern/client";

const required = (name: string): string => {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required`);
  return value;
};

const apiKey = required("LUMBOX_API_KEY");
const skyvernApiKey = required("SKYVERN_API_KEY");
const publicBaseUrl = new URL(required("PUBLIC_HTTPS_BASE_URL"));
if (publicBaseUrl.protocol !== "https:") throw new Error("PUBLIC_HTTPS_BASE_URL must use HTTPS");
const signupUrl = required("SIGNUP_URL");
const expectedSender = required("EXPECTED_SENDER");
const signupInstructions = required("SIGNUP_INSTRUCTIONS");
const apiUrl = process.env.LUMBOX_API_URL ?? "https://api.lumbox.co";
const port = Number(process.env.PORT ?? "8787");

async function createInbox() {
  const response = await fetch(`${apiUrl}/v1/inboxes`, {
    method: "POST",
    headers: { "X-API-Key": apiKey, "Content-Type": "application/json" },
    body: JSON.stringify({}),
  });
  if (!response.ok) throw new Error(`Lumbox inbox creation failed with HTTP ${response.status}`);
  const inbox = await response.json() as { id: string; address: string };
  if (!inbox.id || !inbox.address) throw new Error("Lumbox returned an incomplete inbox response");
  return inbox;
}

function verifySkyvernSignature(rawBody: Buffer, signature: string | undefined): boolean {
  if (!signature || !/^[a-f0-9]{64}$/i.test(signature)) return false;
  const expected = createHmac("sha256", skyvernApiKey).update(rawBody).digest("hex");
  const receivedBytes = Buffer.from(signature, "utf8");
  const expectedBytes = Buffer.from(expected, "utf8");
  return receivedBytes.length === expectedBytes.length && timingSafeEqual(receivedBytes, expectedBytes);
}

function readBody(request: IncomingMessage): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    let size = 0;
    request.on("data", (chunk: Buffer) => {
      size += chunk.length;
      if (size > 16_384) {
        reject(new Error("Request body too large"));
        request.destroy();
        return;
      }
      chunks.push(chunk);
    });
    request.on("end", () => resolve(Buffer.concat(chunks)));
    request.on("error", reject);
  });
}

function reply(response: ServerResponse, status: number, body: object) {
  response.writeHead(status, {
    "Content-Type": "application/json; charset=utf-8",
    "Cache-Control": "no-store",
  });
  response.end(JSON.stringify(body));
}

async function main() {
  const inbox = await createInbox();
  const callbackPath = `/skyvern/${randomBytes(24).toString("hex")}`;
  const callbackUrl = new URL(callbackPath, publicBaseUrl).toString();
  const callbacks = new Map([[callbackPath, {
    inboxId: inbox.id,
    since: "",
    from: expectedSender,
  }]]);

  const server = createServer(async (request, response) => {
    const route = callbacks.get(request.url ?? "");
    if (!route) return reply(response, 404, { error: "Not found" });
    if (request.method !== "POST") return reply(response, 405, { error: "Method not allowed" });

    let rawBody: Buffer;
    try {
      rawBody = await readBody(request);
    } catch {
      return reply(response, 413, { error: "Request body too large" });
    }

    const signatureHeader = request.headers["x-skyvern-signature"];
    const signature = Array.isArray(signatureHeader) ? undefined : signatureHeader;
    if (!verifySkyvernSignature(rawBody, signature)) {
      return reply(response, 401, { error: "Invalid signature" });
    }

    const timestamp = request.headers["x-skyvern-timestamp"];
    if (typeof timestamp !== "string" || !/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return reply(response, 401, { error: "Invalid timestamp" });
    }

    let payload: Record<string, unknown>;
    try {
      payload = JSON.parse(rawBody.toString("utf8")) as Record<string, unknown>;
    } catch {
      return reply(response, 400, { error: "Malformed JSON" });
    }
    if (!["task_id", "workflow_run_id", "workflow_permanent_id"].some((key) => typeof payload[key] === "string")) {
      return reply(response, 400, { error: "Missing Skyvern run identifier" });
    }

    const query = new URLSearchParams({ timeout: "1", since: route.since, from: route.from });
    let upstream: Response;
    try {
      upstream = await fetch(`${apiUrl}/v1/inboxes/${encodeURIComponent(route.inboxId)}/otp?${query}`, {
        headers: { "X-API-Key": apiKey },
      });
    } catch {
      return reply(response, 502, { error: "Lumbox request failed" });
    }

    if (upstream.status === 408) return reply(response, 200, { verification_code: "" });
    if (!upstream.ok) return reply(response, 502, { error: `Lumbox returned HTTP ${upstream.status}` });
    const otp = await upstream.json() as { code?: string };
    if (typeof otp.code !== "string" || otp.code.length === 0) {
      return reply(response, 200, { verification_code: "" });
    }
    return reply(response, 200, { verification_code: otp.code });
  });

  await new Promise<void>((resolve, reject) => {
    server.once("error", reject);
    server.listen(port, "0.0.0.0", resolve);
  });

  const skyvern = new Skyvern({ apiKey: skyvernApiKey });
  try {
    const since = new Date().toISOString();
    callbacks.set(callbackPath, { inboxId: inbox.id, since, from: expectedSender });
    const result = await skyvern.runTask({
      url: signupUrl,
      prompt: `Create an account with the email address ${inbox.address}. ${signupInstructions}`,
      totp_url: callbackUrl,
      waitForCompletion: true,
      timeout: 900,
    });
    if (result.status !== "completed") {
      throw new Error(`Skyvern task ended with status ${result.status}`);
    }
    console.log("Signup completed. Check the configured success condition on the target site.");
  } finally {
    try {
      await skyvern.close();
    } finally {
      await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
    }
  }
}

main().catch((error: unknown) => {
  console.error(error instanceof Error ? error.message : "Signup failed");
  process.exitCode = 1;
});

The current @skyvern/client wrapper accepts url, prompt, and totp_url as top-level request fields. Its generated lower-level HTTP client uses a body field, but that is not the wrapper API, so this example follows the installed wrapper's types and current task reference.

When Skyvern reaches a verification prompt, it posts a JSON payload containing whichever identifiers apply from task_id, workflow_run_id, and workflow_permanent_id. It signs the normalized payload bytes using HMAC-SHA256 with the Skyvern API key and sends x-skyvern-signature and x-skyvern-timestamp. The callback checks the signature against the raw bytes, rejects stale timestamps, then fetches the Lumbox code. Skyvern treats an empty verification_code as unavailable and polls again after a delay, so the callback keeps its Lumbox wait short.

For a saved workflow, configure the TOTP Verification URL on the block that performs the login. A workflow parameter such as {{ totp_url }} can supply a run-specific callback. Setting only the top-level workflow totp_url does not fill an empty block field.

Lumbox behavior and cleanup

The inbox address is read from Lumbox's create response. Lumbox chooses the default domain from account configuration, so this example does not assume a fixed domain. The OTP request uses since captured before the Skyvern task starts and reuses it for every callback. from is a substring filter. Lumbox searches the newest matching verification email and returns its first extracted code as a string. It does not consume the code or validate the target site's expiry rules.

The OTP endpoint defaults to a 30 second wait and accepts 1 through 120 seconds. No matching code returns HTTP 408. Authentication failures return 401, inbox permission failures return 403, inbox limits return 402 when creating an inbox, and other non-success responses should be handled separately.

This example closes Skyvern and its local callback server in finally. The inbox is a persistent identity and is not deleted automatically. Delete it manually with DELETE /v1/inboxes/:id when it is no longer needed; deletion permanently removes the inbox and its emails.

Troubleshooting

  • A callback 401 usually means the body changed after signing, the signature header is missing, or the timestamp is more than five minutes old.
  • A callback 404 means the requested path does not belong to this inbox. Each run receives a random path bound to its own inbox and since timestamp.
  • A callback 502 indicates a Lumbox request failed or returned an unexpected status. Check the API key, inbox access, and sender substring.
  • A Skyvern task that cannot reach the callback needs a publicly reachable HTTPS base URL forwarded to this process.

Sources checked