Skip to main content

Overview

The Lava checkout component handles the complete customer onboarding flow: phone verification, wallet creation, payment processing, and connection establishment. This guide shows you how to integrate it into your React application with both frontend callbacks and backend webhooks.
Prerequisites: You need a Lava merchant account with a product configured. See the Monetize Quickstart to set up your first product.

Installation

Install the Checkout Package

npm install @lavapayments/checkout

Install the Node.js SDK (Backend)

npm install @lavapayments/nodejs
The checkout component (@lavapayments/checkout) is frontend-only. You’ll need the Node.js SDK (@lavapayments/nodejs) for creating checkout sessions on your backend.

Backend Session Creation

Before embedding the checkout component, create a checkout session on your backend. This ensures security and lets you configure checkout behavior.

Session Types

Lava supports three checkout modes:
ModePurposeWhen to Use
onboardingCreate wallet + add fundsFirst-time user sign-up, no existing wallet
topupAdd funds to existing walletUser needs more credits
subscriptionSubscribe to recurring billingMonthly/annual billing plans

Next.js API Route Example

// app/api/checkout/create-session/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Lava } from '@lavapayments/nodejs';

const lava = new Lava({
  secretKey: process.env.LAVA_SECRET_KEY!
});

export async function POST(req: NextRequest) {
  const { mode, amount, productId, userReferenceId } = await req.json();

  try {
    const session = await lava.checkout.createSession({
      mode: mode,                    // 'onboarding', 'topup', or 'subscription'
      productId: productId,          // Your product secret
      amount: amount,                // In cents (e.g., 5000 = $50.00)
      referenceId: userReferenceId,  // Your internal user ID
      metadata: {
        userId: userReferenceId,
        plan: 'pro',
        source: 'pricing_page'
      }
    });

    return NextResponse.json({
      sessionSecret: session.sessionSecret,
      sessionId: session.sessionId
    });
  } catch (error) {
    console.error('Session creation failed:', error);
    return NextResponse.json(
      { error: 'Failed to create checkout session' },
      { status: 500 }
    );
  }
}

Express.js Example

import express from 'express';
import { Lava } from '@lavapayments/nodejs';

const app = express();
app.use(express.json());

const lava = new Lava({ secretKey: process.env.LAVA_SECRET_KEY! });

app.post('/api/checkout/create-session', async (req, res) => {
  const { mode, amount, productId, userReferenceId } = req.body;

  try {
    const session = await lava.checkout.createSession({
      mode: mode,
      productId: productId,
      amount: amount,
      referenceId: userReferenceId,
      metadata: {
        userId: userReferenceId,
        plan: req.body.plan || 'basic',
        source: 'api'
      }
    });

    res.json({
      sessionSecret: session.sessionSecret,
      sessionId: session.sessionId
    });
  } catch (error) {
    console.error('Session creation error:', error);
    res.status(500).json({ error: 'Failed to create checkout session' });
  }
});
Session secrets expire after 30 minutes. Create a new session for each checkout attempt. Never reuse session secrets across multiple users.

Session Configuration Options

await lava.checkout.createSession({
  mode: 'subscription',
  productId: 'prod_abc123',
  amount: 5000,

  // Optional: Link to your internal user
  referenceId: 'user_xyz789',

  // Optional: Attach custom data
  metadata: {
    userId: 'user_xyz789',
    plan: 'pro',
    annualBilling: true,
    promoCode: 'LAUNCH2024'
  },

  // Optional: Redirect URLs (for hosted checkout)
  successUrl: 'https://yourapp.com/dashboard?checkout=success',
  cancelUrl: 'https://yourapp.com/pricing?checkout=cancelled'
});

Frontend Component Integration

Basic Integration

'use client';  // Next.js App Router

import { LavaCheckout } from '@lavapayments/checkout';
import { useState } from 'react';

export function CheckoutButton() {
  const [sessionSecret, setSessionSecret] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function startCheckout() {
    setLoading(true);

    try {
      // Call your backend to create session
      const response = await fetch('/api/checkout/create-session', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          mode: 'onboarding',
          amount: 5000,  // $50.00
          productId: process.env.NEXT_PUBLIC_LAVA_PRODUCT_ID,
          userReferenceId: 'user_123'
        })
      });

      const { sessionSecret } = await response.json();
      setSessionSecret(sessionSecret);
    } catch (error) {
      console.error('Failed to create session:', error);
      alert('Failed to start checkout. Please try again.');
    } finally {
      setLoading(false);
    }
  }

  return (
    <div>
      {!sessionSecret ? (
        <button onClick={startCheckout} disabled={loading}>
          {loading ? 'Loading...' : 'Get Started - $50 Credit'}
        </button>
      ) : (
        <LavaCheckout
          sessionSecret={sessionSecret}
          onSuccess={(connection) => {
            console.log('Checkout completed!', connection);
            // Save connection details
            saveConnection(connection);
            // Redirect to dashboard
            window.location.href = '/dashboard?checkout=success';
          }}
          onError={(error) => {
            console.error('Checkout error:', error);
            alert('Checkout failed. Please try again.');
            setSessionSecret(null);  // Allow retry
          }}
        />
      )}
    </div>
  );
}

async function saveConnection(connection: any) {
  // Store connection secret in your database
  await fetch('/api/user/connection', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      connectionId: connection.connectionId,
      connectionSecret: connection.connectionSecret
    })
  });
}

With Loading and Error States

import { LavaCheckout } from '@lavapayments/checkout';
import { useState } from 'react';

export function CheckoutFlow() {
  const [sessionSecret, setSessionSecret] = useState<string | null>(null);
  const [checkoutState, setCheckoutState] = useState<'idle' | 'loading' | 'checkout' | 'success' | 'error'>('idle');
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  async function handleStartCheckout() {
    setCheckoutState('loading');
    setErrorMessage(null);

    try {
      const response = await fetch('/api/checkout/create-session', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          mode: 'onboarding',
          amount: 5000,
          productId: process.env.NEXT_PUBLIC_LAVA_PRODUCT_ID
        })
      });

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

      const { sessionSecret } = await response.json();
      setSessionSecret(sessionSecret);
      setCheckoutState('checkout');
    } catch (error) {
      setCheckoutState('error');
      setErrorMessage('Failed to initialize checkout. Please try again.');
    }
  }

  if (checkoutState === 'success') {
    return (
      <div className="success-message">
        <h2>🎉 Welcome!</h2>
        <p>Your wallet has been created. Redirecting to dashboard...</p>
      </div>
    );
  }

  if (checkoutState === 'error') {
    return (
      <div className="error-message">
        <p>{errorMessage}</p>
        <button onClick={() => setCheckoutState('idle')}>Try Again</button>
      </div>
    );
  }

  if (checkoutState === 'loading') {
    return <div>Preparing checkout...</div>;
  }

  if (checkoutState === 'checkout' && sessionSecret) {
    return (
      <LavaCheckout
        sessionSecret={sessionSecret}
        onSuccess={(connection) => {
          setCheckoutState('success');
          saveConnectionToBackend(connection);
          setTimeout(() => {
            window.location.href = '/dashboard';
          }, 2000);
        }}
        onError={(error) => {
          setCheckoutState('error');
          setErrorMessage(error.message || 'Checkout failed');
        }}
      />
    );
  }

  return (
    <button onClick={handleStartCheckout}>
      Start Checkout
    </button>
  );
}

Handling Checkout Completion

You have two options for handling checkout completion: frontend callbacks (faster, less reliable) or backend webhooks (recommended for production).

Option 1: Frontend Callback (Development/Testing)

The onSuccess callback receives connection details immediately:
<LavaCheckout
  sessionSecret={sessionSecret}
  onSuccess={(connection) => {
    // Connection details available immediately
    console.log('Connection ID:', connection.connectionId);
    console.log('Connection Secret:', connection.connectionSecret);
    console.log('Wallet ID:', connection.walletId);

    // Save to your backend
    await fetch('/api/user/save-connection', {
      method: 'POST',
      body: JSON.stringify({
        userId: currentUser.id,
        connectionId: connection.connectionId,
        connectionSecret: connection.connectionSecret
      })
    });

    // Generate forward token for immediate use
    const forwardToken = lava.generateForwardToken({
      connection_secret: connection.connectionSecret,
      product_secret: productSecret
    });

    // Redirect to app
    window.location.href = '/dashboard';
  }}
/>
Pros:
  • ✅ Immediate feedback to user
  • ✅ No webhook setup required
  • ✅ Simple for development
Cons:
  • ❌ User can close browser before callback executes
  • ❌ No server-side verification
  • ❌ Not suitable for production
Configure a webhook to receive checkout.completed events: Step 1: Create webhook endpoint
// app/api/webhooks/lava/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Lava } from '@lavapayments/nodejs';
import crypto from 'crypto';

const lava = new Lava({ secretKey: process.env.LAVA_SECRET_KEY! });

export async function POST(req: NextRequest) {
  const body = await req.text();
  const signature = req.headers.get('x-lava-signature');

  // Verify webhook signature
  const expectedSignature = crypto
    .createHmac('sha256', process.env.LAVA_WEBHOOK_SECRET!)
    .update(body)
    .digest('hex');

  if (signature !== expectedSignature) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  const event = JSON.parse(body);

  // Handle checkout.completed event
  if (event.type === 'checkout.completed') {
    const { connection_id, reference_id, wallet_id, amount } = event.data;

    // Retrieve full connection details
    const connection = await lava.connections.retrieve(connection_id);

    // Save to your database
    await database.users.update({
      where: { id: reference_id },
      data: {
        lavaWalletId: wallet_id,
        lavaConnectionId: connection_id,
        lavaConnectionSecret: connection.connection_secret,
        onboardingCompleted: true,
        initialCreditAmount: amount
      }
    });

    // Send welcome email
    await sendWelcomeEmail(reference_id);

    return NextResponse.json({ received: true });
  }

  return NextResponse.json({ received: true });
}
Step 2: Register webhook in Lava dashboard
  1. Navigate to Monetize > Webhooks
  2. Click “Add Endpoint”
  3. Enter your URL: https://yourapp.com/api/webhooks/lava
  4. Select events: checkout.completed, balance.updated, connection.deleted
  5. Copy the Webhook Secret and add to your environment variables
Always verify webhook signatures. Without verification, malicious actors could send fake checkout completion events to your endpoint.

Webhook + Callback Pattern (Best Practice)

Combine both for the best user experience:
<LavaCheckout
  sessionSecret={sessionSecret}
  onSuccess={(connection) => {
    // Show success message immediately (UX)
    setCheckoutState('success');
    showSuccessToast('Wallet created! Setting up your account...');

    // Redirect to dashboard (webhook will finalize in background)
    setTimeout(() => {
      window.location.href = '/dashboard?checkout=success';
    }, 2000);
  }}
/>
Backend webhook:
if (event.type === 'checkout.completed') {
  // Webhook finalizes setup
  await setupUserAccount(event.data);
  await sendWelcomeEmail(event.data.reference_id);

  return NextResponse.json({ received: true });
}
This pattern provides immediate user feedback while ensuring reliable backend processing.

Phone Verification Flow

The checkout component includes automatic phone verification via SMS OTP:

How It Works

  1. User enters phone number in checkout component
  2. Lava sends 6-digit code via Twilio SMS
  3. User enters code to verify ownership
  4. Wallet created and linked to verified phone
  5. Payment processed after successful verification

Verification States

The component handles all verification states automatically:
  • Waiting for code - User sees OTP input field
  • Verifying code - Loading state while validating
  • Code expired - Option to resend after 10 minutes
  • Invalid code - Error message with retry option
  • Verified - Proceeds to payment step
Phone numbers can only be linked to one wallet. If a user tries to create a wallet with an existing phone number, they’ll be prompted to log into their existing wallet instead.

Rate Limiting

Lava implements rate limiting to prevent abuse:
  • 5 OTP attempts per phone number per hour
  • 10 checkout sessions per IP address per hour
  • 3 failed payment attempts per session
If rate limits are exceeded, users see a friendly error message with retry instructions.

Customization Options

Theme Customization

<LavaCheckout
  sessionSecret={sessionSecret}
  onSuccess={handleSuccess}
  theme={{
    // Colors
    primaryColor: '#4f000b',      // Primary brand color
    backgroundColor: '#ffffff',   // Background color
    textColor: '#1a1a1a',         // Text color
    errorColor: '#dc2626',        // Error messages

    // Typography
    fontFamily: 'Inter, system-ui, sans-serif',
    fontSize: '16px',

    // Borders & Radius
    borderRadius: '8px',
    borderColor: '#e5e7eb',

    // Spacing
    padding: '24px',

    // Dark mode support
    darkMode: false
  }}
/>

Custom CSS Classes

<LavaCheckout
  sessionSecret={sessionSecret}
  onSuccess={handleSuccess}
  className="max-w-md mx-auto shadow-lg"
  inputClassName="border-2 border-gray-300 focus:border-blue-500"
  buttonClassName="bg-blue-600 hover:bg-blue-700 text-white"
/>

Locale and Currency

<LavaCheckout
  sessionSecret={sessionSecret}
  onSuccess={handleSuccess}
  locale="en-US"           // 'en-US', 'es-ES', 'fr-FR'
  currency="USD"           // 'USD', 'EUR', 'GBP'
  phoneRegion="US"         // Default country for phone input
/>

Troubleshooting

Cause: Checkout session was created more than 30 minutes agoSolution:
  • Sessions expire after 30 minutes for security
  • Create a new session when user clicks checkout button
  • Don’t pre-create sessions on page load
Prevention:
// ✅ Create session on button click
<button onClick={createAndStartCheckout}>Start Checkout</button>

// ❌ Don't create session on page load
useEffect(() => {
  createCheckoutSession();  // Will expire if user waits
}, []);
Cause: Missing session secret or invalid formatSolution:
  1. Verify session secret is passed correctly
  2. Check browser console for errors
  3. Ensure @lavapayments/checkout is installed
  4. Confirm component is in a client component ('use client' directive)
Debug checklist:
console.log('Session secret:', sessionSecret);  // Should start with 'cs_'
console.log('Session secret length:', sessionSecret?.length);  // Should be >20 chars
Cause: Invalid phone number format or OTP delivery issuesSolution:
  • Use E.164 format: +1234567890 (no spaces or dashes)
  • Check phone number is valid and can receive SMS
  • Verify Twilio integration is working (Lava dashboard)
  • Check for rate limiting (5 attempts per hour per phone)
Common mistakes:
  • (555) 123-4567 (formatted)
  • 555-123-4567 (missing country code)
  • +15551234567 (E.164 format)
Cause: Incorrect webhook URL, firewall blocking, or signature verification failingSolution:
  1. Test webhook URL is publicly accessible: Use webhook.site to verify
  2. Check webhook logs in Lava dashboard for delivery attempts
  3. Verify signature validation is correct (use HMAC SHA-256)
  4. Ensure webhook endpoint returns 200 status code
  5. Check firewall/CORS settings aren’t blocking requests
Local testing: Use ngrok to expose local dev server:
ngrok http 3000
# Use ngrok URL in webhook configuration: https://abc123.ngrok.io/api/webhooks/lava

Next Steps