> ## Documentation Index
> Fetch the complete documentation index at: https://docs.browserbase.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Stagehand + Link SDK

> Prepare a flower delivery with Stagehand on Browserbase, then use Link to request approval and pay.

Send a small **Floral Embrace** bouquet from **1-800-Flowers** on the next available delivery date, within a **\$100 total budget**. Stagehand handles the website. Link asks the user to approve the order and supplies the payment card.

## 1. Set up

You need Node.js 22.18 or newer and pnpm 10 or newer. Set `BROWSERBASE_API_KEY` in your environment to your [Browserbase API key](https://www.browserbase.com/settings):

```bash theme={null}
export BROWSERBASE_API_KEY="your_browserbase_api_key"
```

Create the project and install the dependencies:

```bash theme={null}
pnpx create-browser-app link-flowers
cd link-flowers
pnpm add --save-exact @browserbasehq/stagehand@4.1.0 @stripe/link-sdk@0.4.2 zod@4.4.3
```

For checkout, you need a Link wallet with an eligible payment method and a user-authorized access token. Follow [Stripe’s OAuth setup](https://docs.stripe.com/agentic-commerce/link-cli/oauth), which requires contacting Stripe to register a hosted app, then supply the token as `LINK_ACCESS_TOKEN` through your secret manager. The SDK accepts the token; your application handles login and refresh. For a local agent with built-in login, use the [Browse CLI guide](/integrations/stripe/browse-cli).

## 2. Run the example

Copy the complete file into `index.ts`. Start with `recipient: null` to inspect the cart. To continue to checkout, add the recipient’s details, the buyer’s email and phone, and a billing address, then give the order a unique `orderId`. Link may return only part of the billing address, so the example uses yours when needed.

<Accordion title="Complete index.ts">
  ```typescript Node.js theme={null}
  import {
    browserbase,
    Stagehand,
    type Locator,
    type Page,
  } from "@browserbasehq/stagehand";
  import Link, { getDuplicateSpendRequest } from "@stripe/link-sdk";
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
  import { homedir } from "node:os";
  import { join } from "node:path";
  import { setTimeout as delay } from "node:timers/promises";
  import { isDeepStrictEqual } from "node:util";
  import { z } from "zod/v4";

  const recipientSchema = z.object({
    firstName: z.string().min(1),
    lastName: z.string().min(1),
    address1: z.string().min(1),
    address2: z.string().optional(),
    city: z.string().min(1),
    state: z.string().min(1),
    postalCode: z.string().regex(/^\d{5}$/),
    phone: z.string().min(1),
  });

  const billingAddressSchema = z.object({
    name: z.string().min(1),
    line1: z.string().min(1),
    line2: z.string().optional(),
    city: z.string().min(1),
    state: z.string().length(2),
    postal_code: z.string().min(1),
    country: z.literal("US"),
  });

  const flowersConfigSchema = z
    .object({
      productUrl: z
        .url()
        .refine(
          (url) => URL.parse(url)?.origin === "https://www.1800flowers.com",
          {
            message: "Use a 1-800-Flowers product URL",
          },
        ),
      product: z.string().min(1),
      sku: z.string().min(1),
      size: z.string().min(1),
      deliveryZip: z.string().regex(/^\d{5}$/, "Use a five-digit delivery ZIP"),
      deliveryTimeZone: z.string().min(1),
      maxTotal: z.number().int().positive(),
      orderId: z.string().regex(/^[a-zA-Z0-9_-]+$/),
      buyerEmail: z.email().nullable(),
      buyerPhone: z.string().min(1).nullable(),
      billingAddress: billingAddressSchema.nullable(),
      testMode: z.boolean(),
      recipient: recipientSchema.nullable(),
      giftMessage: z.string(),
      giftSignature: z.string().min(1),
    })
    .refine(
      (value) =>
        !value.recipient || value.recipient.postalCode === value.deliveryZip,
      {
        message: "Recipient ZIP must match the delivery ZIP",
        path: ["recipient", "postalCode"],
      },
    );
  type FlowersConfig = z.infer<typeof flowersConfigSchema>;

  // Update these values for your flower delivery.
  const flowerDelivery: FlowersConfig = {
    productUrl: "https://www.1800flowers.com/floral-embrace-191167",
    product: "Floral Embrace™",
    sku: "191167S", // Item number for the Small bouquet.
    size: "Small",
    deliveryZip: "94107", // Update both the ZIP and time zone for the destination.
    deliveryTimeZone: "America/Los_Angeles",
    maxTotal: 10000, // Total budget in cents, including delivery, fees, and tax.
    orderId: "flowers-001", // Use a new ID for each new order.
    buyerEmail: null, // Set your contact details when you add the recipient.
    buyerPhone: null,
    billingAddress: null, // Your billing address, which may differ from delivery.
    testMode: true, // Test credentials do not charge your Link payment method.
    recipient: null, // Add recipient details; null stops at the form.
    giftMessage: "Thinking of you!",
    giftSignature: "Your friend",
  };

  async function main() {
    if (!process.env.BROWSERBASE_API_KEY) {
      throw new Error("Supply a Browserbase API key");
    }
    const config = flowersConfigSchema.parse(flowerDelivery);

    const browser = await browserbase.launch({
      apiKey: process.env.BROWSERBASE_API_KEY,
      proxies: true,
      browserSettings: { recordSession: false, logSession: false },
    });
    try {
      const stagehand = await Stagehand.create({
        browser,
        domSettleTimeoutMs: 15000,
        model: { modelName: "openai/gpt-5.4-mini" },
      });
      try {
        const [page] = await browser.context.pages();
        const scope = { page, locator: page.locator("body") };
        console.log(
          `Session: https://www.browserbase.com/sessions/${browser.sessionId}`,
        );

        // The merchant can show this promotion on the product or add-ons page.
        async function dismissPromotion() {
          const { data: actions } = await stagehand.observe(
            "Find the control that closes a visible email-signup popup. It may be a link named close dialog or a button named close offer inbox. Return only its click action, or no actions if there is no popup. Do not select an offer or sign up.",
            scope,
          );
          if (actions[0]?.method === "click") {
            const { data } = await stagehand.act(actions[0], scope);
            if (!data.success) {
              throw new Error(data.message);
            }
          }
        }

        await page.goto(config.productUrl, { waitUntil: "load", timeout: 60000 });
        await page.waitForSelector(
          'xpath=//label[contains(., "Delivery Zip Code")]',
          {
            state: "visible",
            timeout: 60000,
          },
        );
        await dismissPromotion();
        let { data: size } = await stagehand.act(
          `Select the ${config.size} bouquet size.`,
          scope,
        );
        // A delayed signup popup can appear after the first observation.
        if (!size.success) {
          await dismissPromotion();
          ({ data: size } = await stagehand.act(
            `Select the ${config.size} bouquet size.`,
            scope,
          ));
        }
        if (!size.success) {
          throw new Error(size.message);
        }
        const { data: zip } = await stagehand.act(
          "Fill %zip% into the Delivery Zip Code field.",
          {
            ...scope,
            variables: { zip: config.deliveryZip },
          },
        );
        if (!zip.success) {
          throw new Error(zip.message);
        }
        await dismissPromotion();
        const { data: selection } = await stagehand.extract(
          "Read the selected bouquet size, delivery ZIP code, and Location Type in Enter Delivery Destination.",
          z.object({
            size: z.string(),
            deliveryZip: z.string(),
            locationType: z.string(),
          }),
          { ...scope, screenshot: true },
        );
        if (selection.size.toLowerCase() !== config.size.toLowerCase()) {
          throw new Error("Bouquet size changed");
        }
        if (selection.deliveryZip !== config.deliveryZip) {
          throw new Error("Delivery ZIP changed");
        }
        if (selection.locationType !== "Residence") {
          throw new Error("This example requires Residence delivery");
        }
        const { data: openCalendar } = await stagehand.act(
          "Click Add to Cart to open the delivery calendar.",
          scope,
        );
        if (!openCalendar.success) {
          throw new Error(openCalendar.message);
        }
        await page.waitForSelector(
          'xpath=//*[normalize-space(.)="SELECT DELIVERY DATE"]',
          {
            state: "visible",
            timeout: 60000,
          },
        );

        const today = new Intl.DateTimeFormat("en-CA", {
          timeZone: config.deliveryTimeZone,
          year: "numeric",
          month: "2-digit",
          day: "2-digit",
        }).format(new Date());
        const { data: calendar } = await stagehand.extract(
          `Today is ${today} at the delivery destination. Read the calendar's first displayed month. Identify which dates are disabled: a diagonal line through a date means unavailable. Return the earliest enabled date on or after today as YYYY-MM-DD, including today if it is enabled. Dates with a surcharge count as available. Compare the month and year as well as the day.`,
          z.object({
            disabledDates: z.array(z.string()),
            reason: z.string(),
            deliveryDate: z.string().describe("Delivery date as YYYY-MM-DD"),
          }),
          { ...scope, screenshot: true },
        );
        const deliveryDate = z.iso.date().parse(calendar.deliveryDate);
        if (deliveryDate < today) {
          throw new Error("Delivery date is in the past");
        }

        const dateLabel = new Intl.DateTimeFormat("en-US", {
          timeZone: "UTC",
          weekday: "long",
          year: "numeric",
          month: "long",
          day: "numeric",
        }).format(new Date(`${deliveryDate}T12:00:00Z`));
        const { data: dates } = await stagehand.observe(
          `Find the enabled ${dateLabel} date in the FIRST displayed calendar month. Match its weekday as well as its day and month. Return only that date's click action.`,
          scope,
        );
        if (dates[0]?.method !== "click") {
          throw new Error("Stagehand could not find the delivery date");
        }
        const { data: selectDate } = await stagehand.act(dates[0], scope);
        if (!selectDate.success) {
          throw new Error(selectDate.message);
        }
        await page.waitForSelector(
          'xpath=//button[normalize-space(.)="Checkout"]',
          {
            state: "visible",
            timeout: 60000,
          },
        );
        await dismissPromotion();

        const { data: cart } = await stagehand.extract(
          "Read the flower product marked Added to Cart, its item price in integer cents, and its delivery date as YYYY-MM-DD. Use the displayed date, including its year. Exclude optional add-on gifts. The displayed item price is not the final order total.",
          z.object({
            product: z.string(),
            itemAmount: z.number().int().positive(),
            deliveryDate: z.string().describe("Delivery date as YYYY-MM-DD"),
          }),
          { ...scope, screenshot: true },
        );
        if (
          cart.product.replace(/[™®]/g, "").trim() !==
          config.product.replace(/[™®]/g, "").trim()
        ) {
          throw new Error("Cart product changed");
        }
        if (cart.itemAmount > config.maxTotal) {
          throw new Error("Item exceeds the budget");
        }
        if (cart.deliveryDate !== deliveryDate) {
          throw new Error("Cart delivery date changed");
        }

        await dismissPromotion();
        const { data: checkoutActions } = await stagehand.observe(
          "Find the visible Checkout button that continues to recipient delivery information. Exclude Continue Shopping and the optional gifts' Add To Cart buttons.",
          scope,
        );
        if (checkoutActions[0]?.method !== "click") {
          throw new Error("Stagehand could not find Checkout");
        }
        const { data: checkoutButton } = await stagehand.act(checkoutActions[0], {
          ...scope,
        });
        if (!checkoutButton.success) {
          throw new Error(checkoutButton.message);
        }
        await page.waitForSelector(
          'xpath=//*[normalize-space(.)="Delivery Information"]',
          {
            state: "visible",
            timeout: 60000,
          },
        );
        const { data: greeting } = await stagehand.act(
          "Select Complimentary Greeting Message.",
          scope,
        );
        if (!greeting.success) {
          throw new Error(greeting.message);
        }
        const { data: message } = await stagehand.act(
          "Fill %message% into the complimentary gift message field.",
          {
            ...scope,
            variables: { message: config.giftMessage },
          },
        );
        if (!message.success) {
          throw new Error(message.message);
        }
        const { data: signature } = await stagehand.act(
          "Fill %signature% into the From field for the complimentary greeting message.",
          { ...scope, variables: { signature: config.giftSignature } },
        );
        if (!signature.success) throw new Error(signature.message);
        const { data: delivery } = await stagehand.extract(
          "Check whether the recipient delivery form is visible and Complimentary Greeting Message is selected.",
          z.object({
            recipientFormVisible: z.boolean(),
            complimentaryMessage: z.boolean(),
          }),
          scope,
        );
        if (!delivery.recipientFormVisible || !delivery.complimentaryMessage) {
          throw new Error(
            "Recipient form or complimentary greeting message is missing",
          );
        }

        if (!config.recipient) {
          console.log({ status: "DELIVERY_DETAILS_REQUIRED", ...cart });
          return;
        }

        const recipient = config.recipient;
        for (const [field, value] of [
          ["recipient's first name", recipient.firstName],
          ["recipient's last name", recipient.lastName],
          ["delivery street address", recipient.address1],
          ...(recipient.address2
            ? [["apartment or suite", recipient.address2]]
            : []),
          ["delivery ZIP code", recipient.postalCode],
          ["delivery city", recipient.city],
          ["recipient phone number", recipient.phone],
        ]) {
          if (typeof value !== "string" || !value.trim()) {
            throw new Error(`Provide ${field}`);
          }
          const { data: fill } = await stagehand.act(
            `Fill %value% into the ${field} field.`,
            {
              ...scope,
              variables: { value },
            },
          );
          if (!fill.success) {
            throw new Error(fill.message);
          }
        }
        const { data: state } = await stagehand.act(
          "Select %state% as the delivery state.",
          {
            ...scope,
            variables: { state: recipient.state },
          },
        );
        if (!state.success) {
          throw new Error(state.message);
        }
        const { data: saveShipment } = await stagehand.act(
          "Click Save Shipment to Continue.",
          scope,
        );
        if (!saveShipment.success) {
          throw new Error(saveShipment.message);
        }
        await page.waitForSelector(
          'xpath=//button[normalize-space(.)="Continue to Payment"]',
          { state: "visible", timeout: 60000 },
        );

        await payForFlowers(stagehand, page, config, deliveryDate);
      } finally {
        try {
          await stagehand.close();
        } catch (error) {
          console.warn("Stagehand cleanup failed");
        }
      }
    } finally {
      try {
        await browser.close();
      } catch (error) {
        console.warn("Browser cleanup failed");
      }
    }
  }

  type Quote = z.infer<typeof quoteSchema>;
  type PaymentState = {
    idempotencyKey: string;
    spendRequestId?: string;
    phase?: "submitting" | "confirmed";
    order?: { config: FlowersConfig; quote: Quote };
  };

  async function requestFlowerPayment(
    config: FlowersConfig,
    quote: Quote,
    state: PaymentState,
    saveState: (state: PaymentState) => Promise<void>,
  ) {
    if (quote.feesPending !== false) {
      throw new Error("Wait for the complete quote, including all fees");
    }
    if (quote.currency !== "usd") {
      throw new Error("This example requires a USD quote");
    }
    if (!Number.isInteger(quote.amount) || quote.amount <= 0) {
      throw new Error("Quote amount must be a positive integer in cents");
    }
    if (quote.amount > config.maxTotal) {
      throw new Error("The final total exceeds the budget");
    }
    if (!state.idempotencyKey) {
      throw new Error("Save an idempotency key for this order first");
    }
    if (!process.env.LINK_ACCESS_TOKEN) {
      throw new Error("Supply a user-authorized Link token");
    }

    const order = { config, quote };
    if (state.order) {
      if (!isDeepStrictEqual(state.order, order)) {
        throw new Error("The order changed; request fresh approval");
      }
    } else {
      if (state.spendRequestId) {
        throw new Error("The saved request needs its original order");
      }
      state.order = structuredClone(order);
      await saveState(state);
    }

    const link = new Link({ accessToken: process.env.LINK_ACCESS_TOKEN });
    if (!state.spendRequestId) {
      const methods = z
        .array(z.object({ id: z.string(), is_default: z.boolean().optional() }))
        .parse(await link.paymentMethods.list());
      const method = methods.find((value) => value.is_default) ?? methods[0];
      if (!method) {
        throw new Error("Add an eligible Link payment method");
      }

      const request = await link.spendRequests
        .create({
          idempotency_key: state.idempotencyKey,
          payment_details: method.id,
          credential_type: "card",
          amount: quote.amount,
          currency: quote.currency,
          merchant_name: "1-800-Flowers.com",
          merchant_url: "https://www.1800flowers.com",
          context: `The user asked for a ${config.size} ${config.product} bouquet delivered to ZIP ${config.deliveryZip} on ${quote.deliveryDate}, with a complimentary greeting message. The verified total is ${quote.amount / 100} USD, including delivery, service fees, and tax.`,
          request_approval: false,
          test: config.testMode,
        })
        .catch((error: unknown) => {
          const duplicate = getDuplicateSpendRequest(error);
          if (!duplicate) throw error;
          return duplicate;
        });
      state.spendRequestId = request.id;
      await saveState(state);
    }

    const request = await link.spendRequests.retrieve(state.spendRequestId);
    if (!request) {
      throw new Error("Link could not find the saved spend request");
    }
    if (request.status === "created") {
      const approval = await link.spendRequests.requestApproval(request.id);
      return { link, requestId: request.id, approvalUrl: approval.approval_url };
    }
    return { link, requestId: request.id, approvalUrl: request.approval_url };
  }

  async function waitForApproval(link: Link, requestId: string) {
    const deadline = Date.now() + 5 * 60 * 1000;
    do {
      const request = await link.spendRequests.retrieve(requestId);
      if (!request) {
        throw new Error("Link could not find the saved spend request");
      }
      if (request.status === "approved") return request;
      if (request.status === "requires_action") {
        const action = request.status_details?.requires_action?.next_action;
        console.log(action?.display_message, action?.action_url);
        if (action?.resolution !== "auto_resume") {
          throw new Error("Complete the Link action first");
        }
      } else {
        if (request.status !== "pending_approval") {
          throw new Error(`Payment status: ${request.status}`);
        }
      }
      await delay(2000);
    } while (Date.now() < deadline);
    throw new Error("Approval is still pending. Resume the saved request later.");
  }

  const quoteSchema = z.object({
    product: z.string().min(1),
    sku: z.string().min(1),
    quantity: z.number().int().positive(),
    deliveryZip: z.string().min(1),
    deliveryDate: z.string().describe("Delivery date as YYYY-MM-DD"),
    amount: z.number().int().positive(),
    currency: z.literal("usd"),
    feesPending: z.boolean(),
  });

  async function readFlowerQuote(stagehand: Stagehand, page: Page) {
    const scope = { page, locator: page.locator("body") };
    const { data } = await stagehand.extract(
      "Read the saved flower shipment and order summary: bouquet name, item number (SKU), quantity, first five digits of the delivery ZIP, delivery date as YYYY-MM-DD, and final Order Total in integer cents including delivery, fees, and tax. Set feesPending to true if any charge says TBD, is missing, or has not been calculated. Read only the displayed order.",
      quoteSchema,
      scope,
    );
    const quote = quoteSchema.parse(data);
    if (quote.amount !== (await readTotal(page))) {
      throw new Error("The extracted total does not match the displayed total");
    }
    return quote;
  }

  async function readTotal(page: Page) {
    const text = await page
      .locator(
        "xpath=//b[normalize-space()='Order Total']/parent::div/following-sibling::div/b",
      )
      .innerText();
    const match = text.trim().match(/^\$([\d,]+)\.(\d{2})$/);
    if (!match) throw new Error("Could not read the displayed USD total");
    return Number(match[1].replaceAll(",", "")) * 100 + Number(match[2]);
  }

  async function payForFlowers(
    stagehand: Stagehand,
    page: Page,
    config: FlowersConfig,
    deliveryDate: string,
  ) {
    const scope = { page, locator: page.locator("body") };
    if (!config.buyerEmail || !config.buyerPhone) {
      throw new Error("Provide the buyer's email and phone number");
    }
    // Payment calculates the charges; Delivery shows the full shipment summary.
    const { data: calculateCharges } = await stagehand.act(
      "Click Continue to Payment.",
      scope,
    );
    if (!calculateCharges.success) throw new Error(calculateCharges.message);
    await page.waitForSelector(
      'xpath=//*[normalize-space(.)="Payment Information"]',
      { state: "visible", timeout: 60000 },
    );
    const { data: shipmentSummary } = await stagehand.act(
      "Click Delivery in the checkout progress navigation to return to the saved delivery information.",
      scope,
    );
    if (!shipmentSummary.success) throw new Error(shipmentSummary.message);
    await page.waitForSelector(
      'xpath=//button[normalize-space(.)="Continue to Payment"]',
      { state: "visible", timeout: 60000 },
    );
    const quote = await readFlowerQuote(stagehand, page);
    if (
      quote.product.replace(/[™®]/g, "").trim() !==
        config.product.replace(/[™®]/g, "").trim() ||
      quote.sku !== config.sku ||
      quote.quantity !== 1 ||
      quote.deliveryZip !== config.deliveryZip ||
      quote.deliveryDate !== deliveryDate
    )
      throw new Error("The final order does not match the selected flowers");

    const directory = join(
      homedir(),
      ".cache",
      "browserbase-link-flowers",
      config.orderId,
    );
    await mkdir(directory, { recursive: true, mode: 0o700 });
    const lock = join(directory, "running");
    await mkdir(lock); // A second process must not submit the same order.
    const path = join(directory, "order.json");
    try {
      let state: PaymentState = { idempotencyKey: config.orderId };
      try {
        state = JSON.parse(await readFile(path, "utf8"));
      } catch (error) {
        if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
      }
      if (state.phase === "submitting" || state.phase === "confirmed") {
        throw new Error(
          "This order was already submitted. Inspect its result before retrying.",
        );
      }
      const saveState = async (value: PaymentState) => {
        await writeFile(`${path}.tmp`, JSON.stringify(value), { mode: 0o600 });
        await rename(`${path}.tmp`, path);
      };
      await saveState(state);
      const { link, requestId, approvalUrl } = await requestFlowerPayment(
        config,
        quote,
        state,
        saveState,
      );
      console.log(`Approve this flower order: ${approvalUrl}`);
      await waitForApproval(link, requestId);

      const currentQuote = await readFlowerQuote(stagehand, page);
      if (!isDeepStrictEqual(currentQuote, quote)) {
        throw new Error("The order changed while waiting for approval");
      }

      const { data: payment } = await stagehand.act(
        "Click Continue to Payment.",
        scope,
      );
      if (!payment.success) throw new Error(payment.message);
      await page.waitForSelector(
        'xpath=//*[normalize-space(.)="Payment Information"]',
        { state: "visible", timeout: 60000 },
      );
      const { data: optOut } = await stagehand.act(
        "Uncheck the checkbox for email promotions and special offers.",
        scope,
      );
      if (!optOut.success) throw new Error(optOut.message);
      for (const [field, value] of [
        ["email address", config.buyerEmail],
        ["phone number", config.buyerPhone],
      ]) {
        const { data } = await stagehand.act(
          `Fill %value% into the buyer's ${field} field.`,
          {
            ...scope,
            variables: { value },
          },
        );
        if (!data.success) throw new Error(data.message);
      }
      if ((await readTotal(page)) !== quote.amount) {
        throw new Error("The payment total differs from the approved amount");
      }

      // Discover the empty form before retrieving the card.
      const fields = new Map<string, Locator>();
      for (const [key, description] of Object.entries({
        name: "Name on Credit Card input",
        number: "credit-card number input",
        month: "card expiration month dropdown",
        year: "card expiration year dropdown",
        cvc: "card security code or CVV input",
        firstName: "first name input under Billing Information",
        lastName: "last name input under Billing Information",
        address: "street address input under Billing Information",
        zip: "ZIP code input under Billing Information",
        city: "city input under Billing Information",
        state: "state dropdown under Billing Information",
        country: "country dropdown under Billing Information",
        review: "Continue To Review Order button",
      })) {
        const { data: actions } = await stagehand.observe(
          `Find only the ${description}. Return its action without entering a value or clicking it.`,
          scope,
        );
        if (actions.length !== 1)
          throw new Error(`Could not uniquely find ${description}`);
        const field = page.locator(actions[0].selector);
        if ((await field.count()) !== 1)
          throw new Error(`Ambiguous ${description}`);
        fields.set(key, field);
      }

      const approved = await link.spendRequests.retrieve(requestId, {
        include: ["card"],
      });
      if (
        approved?.status !== "approved" ||
        approved.amount !== quote.amount ||
        approved.currency !== quote.currency
      ) {
        throw new Error("Link approval does not match the final order");
      }
      // Parse in this process; never print a validation error containing card data.
      const parsed = z
        .object({
          number: z.string().regex(/^\d{12,19}$/),
          cvc: z.string().regex(/^\d{3,4}$/),
          exp_month: z.number().int().min(1).max(12),
          exp_year: z.number().int().min(2000),
          valid_until: z.string().optional(),
          billing_address: z.unknown().optional(),
        })
        .safeParse(approved.card);
      if (!parsed.success) throw new Error("Link did not return a usable card");
      const card = parsed.data;
      if (
        Date.UTC(card.exp_year, card.exp_month, 1) <= Date.now() ||
        (card.valid_until !== undefined &&
          !(Date.parse(card.valid_until) > Date.now()))
      )
        throw new Error("Link returned an invalid or expired card");
      const returnedBilling = billingAddressSchema.safeParse(
        card.billing_address,
      );
      const billing = returnedBilling.success
        ? returnedBilling.data
        : config.billingAddress;
      if (!billing)
        throw new Error("Provide a complete billingAddress in flowerDelivery");
      const [firstName, ...lastName] = billing.name.trim().split(/\s+/);
      if (!lastName.length)
        throw new Error("The card's billing name needs a first and last name");
      // After this point, use native browser actions and do not call a model.
      try {
        await fields.get("country")!.selectOption(billing.country);
        for (const [key, value] of Object.entries({
          name: billing.name,
          firstName,
          lastName: lastName.join(" "),
          address: [billing.line1, billing.line2].filter(Boolean).join(", "),
          zip: billing.postal_code,
          city: billing.city,
        }))
          await fields.get(key)!.fill(value);
        await fields.get("state")!.selectOption(billing.state);
        await fields.get("number")!.fill(card.number);
        await fields
          .get("month")!
          .selectOption(String(card.exp_month).padStart(2, "0"));
        await fields.get("year")!.selectOption(String(card.exp_year));
        await fields.get("cvc")!.fill(card.cvc);
        state.phase = "submitting";
        await saveState(state);
        await fields.get("review")!.click();

        const submitSelector =
          'xpath=//button[normalize-space(.)="Place Order" or normalize-space(.)="Place Your Order"]';
        await page.waitForSelector(submitSelector, {
          state: "visible",
          timeout: 60000,
        });
        const submit = page.locator(submitSelector);
        if ((await submit.count()) !== 1)
          throw new Error("Could not identify the final order button");
        if ((await readTotal(page)) !== quote.amount) {
          throw new Error("The review total differs from the approved amount");
        }
        await submit.click();
        await page.waitForSelector(
          'xpath=//*[self::h1 or self::h2][contains(translate(., "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), "thank you") or contains(translate(., "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"), "order confirmation")]',
          { state: "visible", timeout: 60000 },
        );
        const text = await page.locator("body").innerText();
        const orderNumber = text.match(
          /Order\s*(?:Number|#|No\.?)\s*:?\s*([A-Z0-9-]{5,})/i,
        )?.[1];
        if (!orderNumber || !/\d/.test(orderNumber))
          throw new Error("No order number found");
        state.phase = "confirmed";
        await saveState(state);
        console.log({ status: "ORDER_CONFIRMED", orderNumber });
      } catch {
        throw new Error(
          "Checkout did not confirm. Inspect the saved request and merchant order before retrying.",
        );
      }
    } finally {
      await rm(lock, { recursive: true });
    }
  }

  main().catch((error) => {
    console.error("Flower order failed:", error.message);
    process.exitCode = 1;
  });
  ```
</Accordion>

```bash theme={null}
pnpm start
```

The script chooses the flowers and delivery date, skips paid extras, and adds a complimentary message. With delivery and contact details, it checks the final total and prints a Link approval URL.

The example starts in [Link test mode](https://docs.stripe.com/agentic-commerce/link-cli/use-link-wallet-pay-online#test-your-integration). Test credentials don't charge your payment method, but the merchant can reject them before the final order step. Set `testMode: false` and use a new `orderId` when you're ready to place a real order.

The sections below explain parts of the complete file.

## 3. Prepare the order

Update `flowerDelivery` with the bouquet, destination, budget, and message. Stagehand chooses the delivery date, skips paid extras, and fills the recipient form. It opens Payment to calculate fees, then returns to the shipment summary to read the complete order:

```typescript Node.js theme={null}
const { data } = await stagehand.extract(
  "Read the saved flower shipment and order summary: bouquet name, item number (SKU), quantity, first five digits of the delivery ZIP, delivery date as YYYY-MM-DD, and final Order Total in integer cents including delivery, fees, and tax. Set feesPending to true if any charge says TBD, is missing, or has not been calculated. Read only the displayed order.",
  quoteSchema,
  { page, locator: page.locator("body") },
);
```

The script checks the selected flowers and delivery details, then rejects an incomplete total or an order over the budget. [DOM settling](https://docs.stagehand.dev/v4/configuration/browser#dom-settle-timeout) gives the merchant time to update before each natural-language `act()` call.

## 4. Request approval

Send Link the merchant, order, and verified total. The request uses the same saved ID when you rerun the script:

```typescript Node.js theme={null}
const request = await link.spendRequests.create({
  idempotency_key: state.idempotencyKey,
  payment_details: method.id,
  credential_type: "card",
  amount: quote.amount,
  currency: quote.currency,
  merchant_name: "1-800-Flowers.com",
  merchant_url: "https://www.1800flowers.com",
  context: `The user asked for a ${config.size} ${config.product} bouquet delivered to ZIP ${config.deliveryZip} on ${quote.deliveryDate}, with a complimentary greeting message. The verified total is ${quote.amount / 100} USD, including delivery, service fees, and tax.`,
  request_approval: false,
  test: config.testMode,
});
```

After saving the request, the script prints its approval URL and checks for approval every two seconds, for up to five minutes. If approval times out, rerun with the same order ID to resume. A changed order needs fresh approval.

## 5. Fill checkout

Use `observe()` to find the empty payment controls. Once the user approves, retrieve the card and fill those controls through native locators:

```typescript Node.js theme={null}
const approved = await link.spendRequests.retrieve(requestId, {
  include: ["card"],
});
// After validating the approved card:
await fields.get("number")!.fill(card.number);
await fields
  .get("month")!
  .selectOption(String(card.exp_month).padStart(2, "0"));
await fields.get("year")!.selectOption(String(card.exp_year));
await fields.get("cvc")!.fill(card.cvc);
```

The complete file also fills the buyer’s contact details and uses your billing address if Link doesn't return a complete one. Card values stay out of model calls, screenshots, and logs.

## 6. Confirm the order

Before continuing, record that checkout has started. Check the review total against the approved amount, then submit once:

```typescript Node.js theme={null}
state.phase = "submitting";
await saveState(state);
await fields.get("review")!.click();
```

A merchant confirmation and order number produce `ORDER_CONFIRMED`. If the result is unclear, inspect the order before retrying. Link approval confirms permission to pay; the merchant’s receipt confirms the order.

<CardGroup cols={2}>
  <Card title="Browse CLI quickstart" icon="terminal" href="/integrations/skills/browse-cli">
    Set up Browse CLI and give your coding agent a browser.
  </Card>

  <Card title="Stagehand docs" icon="code" href="https://docs.stagehand.dev/v4/first-steps/quickstart">
    Build browser interactions with act(), observe(), and extract().
  </Card>
</CardGroup>
