Learn how to build an agentic checkout flow: discover product URLs in chat, capture buyer identity, tokenize card details with Stripe, and confirm a Rye Checkout Intent to place the order.

Who this is for

Developers building AI chat interfaces where users can buy physical products across Amazon, Shopify, and beyond. We’ll use the Vercel AI SDK for tool-calling, Stripe Elements for card collection, and Universal Checkout API for submitting orders via Checkout Intents.

What you’ll build

A minimal Next.js app with a streaming chat. The assistant can:
  1. Search Amazon (and the wider web) to surface real product URLs.
  2. Show a Buy button next to results.
  3. Open a modal that collects buyer identity (name, email, phone) and creates a Checkout Intent with Rye.
  4. Collect card details using Stripe Card Element and tokenize on the client.
  5. Confirm the Checkout Intent by sending the Stripe token to Rye, which places the order on the third‑party site.
We’ll do this with clean file boundaries so you can drop this into an existing project.

See Rye in Action

Watch a complete order flow — from product URL to purchase confirmation — all without leaving your app. AI chat storefront with Rye
You can follow along step-by-step, or clone the demo repo to run the example.

Assumptions

  • Next.js App Router on Node 18+.
  • You have a Rye API key and will start in staging.
  • You have Rye’s Stripe publishable key
    • Staging: pk_test_51LgDhrHGDlstla3fdqlULAne0rAf4Ho6aBV2cobkYQ4m863Sy0W8DNu2HOnUeYTQzQnE4DZGyzvCB8Yzl1r38isl00H9sVKEMu
  • We’ll use the AI SDK’s tool-calling with OpenAI, but you can use a different LLM provider if you’d like.

Project structure

This is the general structure of the application and how we’ll organize our files.
app/
  layout.tsx
  page.tsx
  api/
    chat/route.ts
    checkout/create-intent/route.ts
    checkout/confirm-intent/route.ts
    checkout/get-intent/route.ts
components/
  Chat.tsx
  ChatInput.tsx
  CheckoutModal.tsx
  messages/
    Message.tsx
    ProductGalleryMessage.tsx
lib/
  rye.ts
  types.ts
tools/
  amazon.ts
If you prefer server actions instead of route handlers, you can mirror the same logic in app/actions.tsx.

Setup

Create the starter template:
npx create-next-app@latest
These are the options we’ll use for the demo:
Need to install the following packages:
create-next-app@15.5.0
Ok to proceed? (y) y

 What is your project named? my-app
 Would you like to use TypeScript? Yes
 Which linter would you like to use? None
 Would you like to use Tailwind CSS? Yes
 Would you like your code inside a `src/` directory? Yes
 Would you like to use App Router? (recommended) … Yes
 Would you like to use Turbopack? (recommended) … No
 Would you like to customize the import alias (`@/*` by default)? … Yes
Creating a new Next.js app in /Users/cjav_dev/repos/rye/demo-test/my-app.

Add dependencies

First, install libraries for working with LLMs:
npm install ai @ai-sdk/openai @ai-sdk/react zod
Next, we’ll install some libraries for collecting payment methods:
npm install @stripe/stripe-js @stripe/react-stripe-js
We’ll use these tools for working rendering markdown and parsing HTML.
npm install cheerio react-markdown

Set environment variables

Create a .env file with these environment variables set.
OPENAI_API_KEYhttps://platform.openai.com/settings/organization/api-keys
RYE_API_KEYhttps://staging.console.rye.com/account
RYE_API_BASEEither https://staging.console.rye.com/account or https://console.rye.com/account
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYUse Rye’s Stripe Publishable Key.
// .env
OPENAI_API_KEY=sk-...
RYE_API_KEY=U...                                     # https://staging.console.rye.com/account or https://console.rye.com/account
RYE_API_BASE=https://staging.api.rye.com
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51LgDhrHGDlstla3fdqlULAne0rAf4Ho6aBV2cobkYQ4m863Sy0W8DNu2HOnUeYTQzQnE4DZGyzvCB8Yzl1r38isl00H9sVKEMu
At this point, you should be able to run npm run dev and load the starter app in the browser.

1. Create a basic AI Chat app

We’ll build this chat-based storefront incrementally, starting with a basic AI chat interface that we’ll progressively enhance with commerce capabilities. This approach allows us to establish the core conversational flow first, then layer on product search tools, checkout flows, and payment processing. By the end, you’ll have a complete chat experience where users can discover products through natural conversation, review detailed offers with real-time shipping and tax calculations, and complete purchases seamlessly—all powered by Rye’s commerce infrastructure.

Set up minimal layout

Before we dive into chat components, let’s set up a minimal layout.
import "./globals.css";
import Link from 'next/link'

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <header className="sticky top-0 z-50 flex items-center justify-between w-full h-16 px-4 border-b shrink-0 bg-white ">
          <Link href="/" rel="nofollow" className="mr-2 font-bold">
            Rye Demo
          </Link>
        </header>
        <main className="bg-muted/50 flex flex-1 flex-col pt-16 min-h-[calc(100vh-4rem)]">
          {children}
        </main>
      </body>
    </html>
  );
}

Set up the chat API endpoint

To connect our chat interface to an AI model, we need to set up a server-side API route that handles streaming responses from OpenAI’s GPT-5. This endpoint will process incoming messages, send them to the language model, and stream back responses that our client-side chat components can display in real-time. While we’re starting with basic chat functionality, this foundation is designed to easily accommodate tools for product search and other enhanced features later.
app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import {
  convertToModelMessages,
  InferUITools,
  stepCountIs,
  streamText,
  UIDataTypes,
  UIMessage,
} from 'ai';

// TODO: Add tools to handle product discovery.
const tools = {};

export type UseChatToolsMessage = UIMessage<
  never,
  UIDataTypes,
  InferUITools<typeof tools>
>;

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: openai('gpt-5'),
    system: `You are a helpful AI assistant that can search for products on Amazon and assist with various tasks.`,
    messages: convertToModelMessages(messages),
    stopWhen: stepCountIs(3), // multi-steps for server-side tools
    tools,
  });

  return result.toUIMessageStreamResponse({
    originalMessages: messages
  });
}

Build the chat interface

The chat components handle user input, display responses, and provide the foundation for our upcoming product search and checkout features. For now, they manage basic messaging—we’ll add search results, Buy buttons, and checkout modals next.
The Chat component will eventually manage our Checkout Modal and rendering fulfillment messages, but to start it’ll contain a stream of messages.The useChat hook allows us to connect to the Next.js server endpoint that calls the LLM provider.
app/components/Chat.tsx
'use client';

import ChatInput from './ChatInput';
import Message from './messages/Message';
import { useChat } from '@ai-sdk/react';
import {
  DefaultChatTransport,
  lastAssistantMessageIsCompleteWithToolCalls,
} from 'ai';
import { UseChatToolsMessage } from '@/app/api/chat/route';
import { useEffect, useRef, useState } from 'react';

export default function Chat() {
  const messagesEndRef = useRef<HTMLDivElement>(null);

  const { messages, sendMessage, addToolResult, status } =
    useChat<UseChatToolsMessage>({
      transport: new DefaultChatTransport({ api: '/api/chat' }),
      sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
    });

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  return (
    <div className="flex flex-col h-full max-w-6xl bg-white">
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages?.map(message => (
          <Message
            key={message.id}
            message={message}
          />
        ))}
        {/* Scroll anchor */}
        <div ref={messagesEndRef} />
      </div>

      <div className="border-t bg-white p-6">
        <div className="max-w-5xl mx-auto">
          <ChatInput status={status} onSubmit={(text: string) => sendMessage({ text })} />
        </div>
      </div>
    </div>
  );
}
Restart your server with npm run dev and you should be able to load the basic chat app, send a test message, and see a from the LLM streamed to your browser.

2. Surface products in the chat UI

Next, we’ll add product search to transform our chat from a simple conversation into an interactive shopping experience. The Universal Checkout API needs product URLs to generate offers, so we’ll build a tool that finds products and returns their URLs as tool results. When users express shopping intent, the LLM can search for relevant items and display them with buy buttons in chat.

Add product search tools

We’ll create a product search tool that allows the LLM to find products on Amazon, enabling discovery and purchase within the chat interface.
You might consider integrating with a search engine API like catalog.ai, velou.com, trychannel3.com, etc.
First, let’s define a ShoppingProduct interface that we’ll use throughout the app to represent product data from search results, ensuring consistent typing across our tool responses, UI components, and checkout flows.
app/lib/types.ts
export interface ShoppingProduct {
  name: string;
  price: string;
  imageUrl: string;
  rating: string;
  url: string;
}
We’ll add a new message type that renders product search results as an interactive gallery with buy buttons.

3. Initiate checkout

When a user clicks “Buy” on a product in chat, we already have the product URL, but we still need their identity and shipping details before creating a Checkout Intent with Rye. Collecting this information first ensures Rye can return accurate shipping costs, taxes, and availability so the user sees a complete offer before confirming. We’ll open a modal to gather the buyer’s name, email, phone, and shipping address. Once submitted, that data—together with the product URL—is sent to Rye’s API to create a Checkout Intent, which retrieves live pricing and availability from the merchant.

Setup the checkout API endpoints

To initiate the checkout process, we’ll create two API endpoints for managing our Rye Checkout Intents:
  1. One to create a Checkout Intent with the product URL and the buyer identity information
  2. One to retrieve the Checkout Intent after it’s created so that we can pull and check the state of the Checkout Intent
lib/types.ts
//...
export interface CheckoutIntent {
  id: string;
  buyer: Buyer;
  quantity: number;
  productUrl: string;
  status: string;
  createdAt: string;
  updatedAt: string;
}

Build the checkout interface

Now we’ll create a checkout modal to manage the purchase flow: first collecting buyer identity and shipping information, then creating a Checkout Intent with Rye to fetch live pricing details including shipping costs and taxes, and finally displaying the complete order summary for user confirmation.
app/lib/types.ts
  // ...
  export interface Buyer {
    firstName: string;
    lastName: string;
    email: string;
    phone: string;
    address1: string;
    address2: string;
    city: string;
    province: string;
    country: string;
    postalCode: string;
  }

4. Collect card details and confirm checkout

Once the Checkout Intent is created and the offer is fetched, the Checkout Intent enters the awaiting_confirmation state. At this point, we can collect payment details using Stripe’s Card Element to tokenize the card information, then send that token to our server to confirm the Checkout Intent with Rye, which will place the order on the third-party merchant site. Using Stripe payment method tokenization keeps raw credit card details off your servers (reducing PCI scope) while providing Rye with a secure, single-use token to process the payment.

Setup the confirm intent API endpoint

This server-side route receives the tokenized card details and confirms the Checkout Intent with Rye.
app/api/checkout/confirm-intent/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createRyeClient } from '@/lib/rye';

interface ConfirmIntentRequest {
  checkoutIntentId: string;
  paymentMethodId: string;
}

export async function POST(request: NextRequest) {
  try {
    const body: ConfirmIntentRequest = await request.json();

    // Validate required fields
    if (!body.checkoutIntentId || !body.paymentMethodId) {
      return NextResponse.json(
        { error: 'Missing required fields: checkoutIntentId and paymentMethodId are required' },
        { status: 400 }
      );
    }

    // Initialize Rye client
    const ryeClient = createRyeClient({
      apiKey: process.env.RYE_API_KEY!,
      baseUrl: process.env.RYE_API_BASE!
    });

    // Confirm Checkout Intent with payment method
    const confirmedIntent = await ryeClient.confirmCheckoutIntent(
      body.checkoutIntentId,
      {
        paymentMethod: {
          type: 'stripe_token',
          stripeToken: body.paymentMethodId, // 'tok_visa', // You can use tok_visa for testing if tokenization isn't setup, yet.
        },
      }
    );

    return NextResponse.json({
      success: true,
      checkoutIntent: confirmedIntent,
    });

  } catch (error) {
    console.error('Error confirming Checkout Intent:', error);

    return NextResponse.json(
      {
        error: 'Failed to confirm payment',
        details: error instanceof Error ? error.message : 'Unknown error'
      },
      { status: 500 }
    );
  }
}

Setup Stripe Card Element for payment collection

On the client side, we’ll use React Stripe.js to collect and tokenize payment details securely. Stripe has several web and mobile SDKs for securely tokenizing card details.
Now we’ll set up the Stripe integration to securely collect payment details:
  • loadStripe: Initializes the Stripe.js library with your publishable key, creating a Stripe instance that handles secure communication with Stripe’s servers
  • Elements Provider: Wraps our checkout form to provide Stripe context, enabling secure tokenization of payment data without it touching your servers
  • CardElement: Renders Stripe’s secure card input fields that automatically handle validation, formatting, and PCI compliance
  • Form Submission: When the user submits payment, we use Stripe’s createToken method to securely tokenize the card details, then send that token (not the raw card data) to Rye for payment processing
This approach ensures that sensitive payment information never passes through your application servers - Stripe handles the security while Rye processes the actual transaction.
The Universal Checkout API expects legacy Stripe tokens that start with tok_ not the newer Stripe Payment Method IDs beginning with pm_.
app/components/CheckoutModal.tsx
'use client';

 import { useState } from 'react';
 import { loadStripe } from '@stripe/stripe-js';
 import {
   Elements,
   CardElement,
   useStripe,
   useElements,
 } from '@stripe/react-stripe-js';
 import { ShoppingProduct, CheckoutIntent } from '../lib/types';
 import Image from 'next/image';

 // Initialize Stripe
 const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);

 interface CheckoutModalProps {
   isOpen: boolean;
   onClose: () => void;
   onOrderComplete: (product: ShoppingProduct, checkoutIntent: CheckoutIntent) => void;
   product: ShoppingProduct;
 }

 const CARD_ELEMENT_OPTIONS = {
   style: {
     base: {
       fontSize: '16px',
       color: '#424770',
       '::placeholder': {
         color: '#aab7c4',
       },
     },
     invalid: {
       color: '#9e2146',
     },
   },
 };

 function CheckoutForm({ product, onClose, onOrderComplete }: { product: ShoppingProduct; onClose: () => void; onOrderComplete: (product: ShoppingProduct, checkoutIntent: CheckoutIntent) => void }) {
   const stripe = useStripe();
   const elements = useElements();
   const [step, setStep] = useState<'buyer-info' | 'loading-offer' | 'payment'>('buyer-info');
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);
   const [checkoutIntent, setCheckoutIntent] = useState<any | null>(null);

   const [buyerInfo, setBuyerInfo] = useState<BuyerInfo>({
     firstName: '',
     lastName: '',
     email: '',
     phone: '',
     address1: '',
     address2: '',
     city: '',
     province: '',
     country: 'US',
     postalCode: '',
   });

   const handleBuyerInfoSubmit = async (e: React.FormEvent) => {
     // See previous section
   };

   const handlePaymentSubmit = async (e: React.FormEvent) => {
     e.preventDefault();

     if (!stripe || !elements || !checkoutIntent) {
       return;
     }

     setLoading(true);
     setError(null);

     const cardElement = elements.getElement(CardElement);
     if (!cardElement) {
       setError('Card element not found');
       setLoading(false);
       return;
     }

     try {
       // Create payment method
       const { error: stripeError, token } = await stripe.createToken(cardElement, {
         name: `${buyerInfo.firstName} ${buyerInfo.lastName}`,
         address_line1: buyerInfo.address1,
         address_line2: buyerInfo.address2,
         address_city: buyerInfo.city,
         address_state: buyerInfo.province,
         address_zip: buyerInfo.postalCode,
         address_country: buyerInfo.country,
       });

       if (stripeError) {
         throw new Error(stripeError.message);
       }

       // Confirm Checkout Intent with payment method
       const response = await fetch('/api/checkout/confirm-intent', {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json',
         },
         body: JSON.stringify({
           checkoutIntentId: checkoutIntent.id,
           paymentMethodId: token.id,
         }),
       });

       if (!response.ok) {
         throw new Error('Failed to confirm payment');
       }

       const result = await response.json();

       // Success! Close modal and show success message
       console.log('result', result);

       // add message that says "placing order..." then poll the GET checkout-intent endpoint every second until it's either successful or failed.
       const pollCheckoutIntent = async () => {
         const response = await fetch(`/api/checkout/get-intent?checkoutIntentId=${checkoutIntent.id}`);
         const { checkoutIntent: updatedIntent } = await response.json();
         if (updatedIntent.state == 'completed') {
           alert('Order placed successfully! You will receive a confirmation email shortly.');
           onOrderComplete(product, updatedIntent);
           onClose();
           setLoading(false);
         } else if (updatedIntent.state == 'failed') {
           alert('Order failed. Please try again.');
           onClose();
           setLoading(false);
         } else if (updatedIntent.state == 'placing_order') {
           console.log("Still placing order...");
           setTimeout(pollCheckoutIntent, 1000);
         }
       };
       pollCheckoutIntent();

     } catch (err) {
       setError(err instanceof Error ? err.message : 'Payment failed');
     }
   };

   const pollForOfferData = async (checkoutIntentId: string) => {
     // See previous section.
   };

   const handleInputChange = (field: keyof BuyerInfo, value: string) => {
     setBuyerInfo(prev => ({ ...prev, [field]: value }));
   };

   return (
     <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
       {/* Product Information - Left Side */}
       <div className="space-y-4">
         <h3 className="text-lg font-semibold mb-4">Product Details</h3>

         <div className="bg-gray-50 p-6">
           {product.imageUrl && product.imageUrl !== 'Image not found' && (
             <div className="mb-4">
               <Image
                 src={product.imageUrl}
                 alt={product.name}
                 className='object-contain mx-auto w-full max-w-[100px]'
                 width={100}
                 height={100}
               />
             </div>
           )}

           <div className="space-y-2">
             <h4 className="font-medium text-gray-800 text-lg">{product.name}</h4>
             {step === 'buyer-info' && (
               <>
                 <p className="text-2xl font-bold text-green-600">{product.price}</p>
                 {product.rating && product.rating !== 'Rating not available' && (
                   <p className="text-sm text-yellow-600 flex items-center gap-1">
                     <span>⭐</span>
                     <span>{product.rating}</span>
                   </p>
                 )}
               </>
             )}
             {(step === 'loading-offer' || step === 'payment') && checkoutIntent && (
               <p className="text-sm text-gray-600">Quantity: {checkoutIntent.quantity}</p>
             )}
           </div>
         </div>

         {(step === 'loading-offer' || step === 'payment') && checkoutIntent && (
           <div className="space-y-4">
             {/* Cost Breakdown */}
             {step === 'payment' && checkoutIntent.offer ? (
               <div className="bg-white border border-gray-200 p-4">
                 <h4 className="font-medium text-gray-800 mb-3">Order Summary</h4>
                 <div className="space-y-2 text-sm">
                   <div className="flex justify-between">
                     <span className="text-gray-600">Subtotal</span>
                     <span className="text-gray-800">
                       ${(checkoutIntent.offer.cost.subtotal.amountSubunits / 100).toFixed(2)}
                     </span>
                   </div>
                   <div className="flex justify-between">
                     <span className="text-gray-600">Shipping</span>
                     <span className="text-gray-800">
                       ${(checkoutIntent.offer.shipping.availableOptions.find((opt: any) =>
                         opt.id === checkoutIntent.offer.shipping.selectedOptionId
                       )?.cost.amountSubunits / 100 || 0).toFixed(2)}
                     </span>
                   </div>
                   <div className="flex justify-between">
                     <span className="text-gray-600">Tax</span>
                     <span className="text-gray-800">
                       ${(checkoutIntent.offer.cost.tax.amountSubunits / 100).toFixed(2)}
                     </span>
                   </div>
                   <div className="border-t pt-2 mt-2">
                     <div className="flex justify-between font-semibold text-base">
                       <span className="text-gray-800">Total</span>
                       <span className="text-green-600">
                         ${(checkoutIntent.offer.cost.total.amountSubunits / 100).toFixed(2)}
                       </span>
                     </div>
                   </div>
                 </div>
               </div>
             ) : (
               <div className="bg-white border border-gray-200 p-4">
                 <h4 className="font-medium text-gray-800 mb-3">Order Summary</h4>
                 <div className="space-y-2 text-sm">
                   <div className="flex justify-between">
                     <span className="text-gray-600">Subtotal</span>
                     <span className="text-gray-400">Calculating...</span>
                   </div>
                   <div className="flex justify-between">
                     <span className="text-gray-600">Shipping</span>
                     <span className="text-gray-400">Calculating...</span>
                   </div>
                   <div className="flex justify-between">
                     <span className="text-gray-600">Tax</span>
                     <span className="text-gray-400">Calculating...</span>
                   </div>
                   <div className="border-t pt-2 mt-2">
                     <div className="flex justify-between font-semibold text-base">
                       <span className="text-gray-800">Total</span>
                       <span className="text-gray-400">Calculating...</span>
                     </div>
                   </div>
                 </div>
               </div>
             )}
           </div>
         )}
       </div>

       {/* Form - Right Side */}
       <div className="space-y-4">
         {step === 'buyer-info' ? (
           <form onSubmit={handleBuyerInfoSubmit} className="space-y-4">
             <h3 className="text-lg font-semibold mb-4">Buyer Information</h3>

           <div className="grid grid-cols-2 gap-4">
             <div>
               <label className="block text-sm font-medium text-gray-700 mb-1">
                 First Name *
               </label>
               <input
                 type="text"
                 required
                 value={buyerInfo.firstName}
                 onChange={(e) => handleInputChange('firstName', e.target.value)}
                 className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
               />
             </div>
             <div>
               <label className="block text-sm font-medium text-gray-700 mb-1">
                 Last Name *
               </label>
               <input
                 type="text"
                 required
                 value={buyerInfo.lastName}
                 onChange={(e) => handleInputChange('lastName', e.target.value)}
                 className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
               />
             </div>
           </div>

           <div>
             <label className="block text-sm font-medium text-gray-700 mb-1">
               Email *
             </label>
             <input
               type="email"
               required
               value={buyerInfo.email}
               onChange={(e) => handleInputChange('email', e.target.value)}
               className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
             />
           </div>

           <div>
             <label className="block text-sm font-medium text-gray-700 mb-1">
               Phone *
             </label>
             <input
               type="tel"
               required
               value={buyerInfo.phone}
               onChange={(e) => handleInputChange('phone', e.target.value)}
               className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
             />
           </div>

           <div>
             <label className="block text-sm font-medium text-gray-700 mb-1">
               Address Line 1 *
             </label>
             <input
               type="text"
               required
               value={buyerInfo.address1}
               onChange={(e) => handleInputChange('address1', e.target.value)}
               className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
             />
           </div>

           <div>
             <label className="block text-sm font-medium text-gray-700 mb-1">
               Address Line 2
             </label>
             <input
               type="text"
               value={buyerInfo.address2}
               onChange={(e) => handleInputChange('address2', e.target.value)}
               className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
             />
           </div>

           <div className="grid grid-cols-2 gap-4">
             <div>
               <label className="block text-sm font-medium text-gray-700 mb-1">
                 City *
               </label>
               <input
                 type="text"
                 required
                 value={buyerInfo.city}
                 onChange={(e) => handleInputChange('city', e.target.value)}
                 className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
               />
             </div>
             <div>
               <label className="block text-sm font-medium text-gray-700 mb-1">
                 State/Province *
               </label>
               <input
                 type="text"
                 required
                 value={buyerInfo.province}
                 onChange={(e) => handleInputChange('province', e.target.value)}
                 className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
               />
             </div>
           </div>

           <div className="grid grid-cols-2 gap-4">
             <div>
               <label className="block text-sm font-medium text-gray-700 mb-1">
                 Country *
               </label>
               <select
                 required
                 value={buyerInfo.country}
                 onChange={(e) => handleInputChange('country', e.target.value)}
                 className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
               >
                 <option value="US">United States</option>
                 <option value="CA">Canada</option>
                 <option value="GB">United Kingdom</option>
                 <option value="AU">Australia</option>
               </select>
             </div>
             <div>
               <label className="block text-sm font-medium text-gray-700 mb-1">
                 Postal Code *
               </label>
               <input
                 type="text"
                 required
                 value={buyerInfo.postalCode}
                 onChange={(e) => handleInputChange('postalCode', e.target.value)}
                 className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
               />
             </div>
           </div>

           {error && (
             <div className="text-red-600 text-sm">{error}</div>
           )}

             <div className="flex gap-3 pt-4">
               <button
                 type="button"
                 onClick={onClose}
                 className="flex-1 px-4 py-2 text-gray-700 bg-gray-200 hover:bg-gray-300 transition-colors"
               >
                 Cancel
               </button>
               <button
                 type="submit"
                 disabled={loading}
                 className="flex-1 px-4 py-2 bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors"
               >
                 {loading ? 'Processing...' : 'Continue to Payment'}
               </button>
             </div>
           </form>
         ) : step === 'loading-offer' ? (
           <div className="space-y-6">
             <h3 className="text-lg font-semibold">Getting Pricing Information</h3>
             <div className="flex flex-col items-center justify-center py-8 space-y-4">
               <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
               <p className="text-gray-600 text-center">
                 Calculating shipping, taxes, and total cost...
               </p>
               <p className="text-sm text-gray-500 text-center">
                 This usually takes a few seconds
               </p>
             </div>
           </div>
         ) : (
           <div className="space-y-6">
             <h3 className="text-lg font-semibold">Payment Information</h3>

             <div className="bg-gray-50 p-4">
               <h4 className="font-medium text-gray-800 mb-3">Shipping Information</h4>
               <div className="text-sm space-y-1">
                 <p className="font-medium text-gray-900">
                   {buyerInfo.firstName} {buyerInfo.lastName}
                 </p>
                 <p className="text-gray-700">{buyerInfo.email}</p>
                 <p className="text-gray-700">{buyerInfo.phone}</p>
                 <div className="mt-2 text-gray-700">
                   <p>{buyerInfo.address1}</p>
                   {buyerInfo.address2 && <p>{buyerInfo.address2}</p>}
                   <p>
                     {buyerInfo.city}, {buyerInfo.province} {buyerInfo.postalCode}
                   </p>
                   <p>{buyerInfo.country}</p>
                 </div>
               </div>
             </div>

             <form onSubmit={handlePaymentSubmit} className="space-y-4">
               <div>
                 <label className="block text-sm font-medium text-gray-700 mb-2">
                   Card Information *
                 </label>
                 <div className="border border-gray-300 p-3">
                   <CardElement options={CARD_ELEMENT_OPTIONS} />
                 </div>
               </div>

               {error && (
                 <div className="text-red-600 text-sm">{error}</div>
               )}

               <div className="flex gap-3 pt-4">
                 <button
                   type="button"
                   onClick={() => setStep('buyer-info')}
                   className="flex-1 px-4 py-2 text-gray-700 bg-gray-200 hover:bg-gray-300 transition-colors"
                 >
                   Back
                 </button>
                 <button
                   type="submit"
                   disabled={loading || !stripe}
                   className="flex-1 px-4 py-2 bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors"
                 >
                   {loading ? 'Processing...' : 'Complete Purchase'}
                 </button>
               </div>
             </form>
           </div>
         )}
       </div>
     </div>
   );
 }

 export default function CheckoutModal({ isOpen, onClose, product, onOrderComplete }: CheckoutModalProps) {
   if (!isOpen) return null;

   return (
     <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
       <div className="bg-white max-w-6xl w-full max-h-[90vh] overflow-y-auto">
         <div className="p-6">
           <div className="flex justify-between items-center mb-6">
             <h2 className="text-xl font-bold text-gray-800">Purchase Product</h2>
             <button
               onClick={onClose}
               className="text-gray-500 hover:text-gray-700 text-2xl"
             >
               ×
             </button>
           </div>

           <Elements stripe={stripePromise}>
             <CheckoutForm product={product} onClose={onClose} onOrderComplete={onOrderComplete} />
           </Elements>
         </div>
       </div>
     </div>
   );
 }

5. Try it out

  1. Run the app: npm run dev
  2. Ask: “Find a stainless steel water bottle on Amazon under $30.”
  3. Click Buy on one result.
  4. Enter your test identity and card details (Stripe test numbers, e.g., 4242 4242 4242 4242).
  5. Submit and you should see Order submitted! when the Rye confirmation returns 200.
View test orders in the Rye console.

Next steps

Store buyer identity (optional)

If your app authenticates users and stores account information, consider storing buyer identity so that you can prepopulate the buyer information during checkout.

Handle edge cases

  • Out-of-stock / price changed: show a “refresh price/stock” button that re-queries.
  • Create a new Checkout Intent if the buyer identity changes to fetch a new offer with updated shipping and tax details.

Security

  • Keep the RYE_API_KEY and OPENAI_API_KEY server-side only.
  • Do not log card data; tokens are safe to log sparingly in staging only.
  • Serve over HTTPS in production.

FAQ

  • Do I need a Stripe account? Not when using the Rye confirmation flow, in this setup, Rye’s Stripe account is used when charging the end customer and paying out the third party merchant.
  • Do I need a Stripe secret key? Not for basic tokenization via Card Element. You only need the Rye publishable key on the client.

Recap

You now have:
  • A streaming chat with tool-calling.
  • A basic Amazon search tool that returns real product URLs.
  • A buyer identity modal that creates a Checkout Intent.
  • Stripe Card Element to tokenize
  • Rye Universal Checkout API to confirm and place the order.