Code1180
PayPal Fastlane Checkout in Laravel (React + TypeScript)
PayPal Fastlane Checkout in Laravel (React + TypeScript)
PayPal Fastlane is a fast guest checkout experience that can return customer details after an email lookup and let you charge the customer without a full form every time. In this tutorial, you will build a complete PayPal Fastlane integration using the Laravel 12 React starter kit (TypeScript), Inertia, and PayPal webhooks.
This guide is written for junior developers but covers the full scope end-to-end. You will look up a customer with Fastlane, render details, create an order, and verify the payment status using webhooks.
Table of Contents
- Overview
- Prerequisites
- What We Are Building
- Step 1: Prepare Your Fastlane Sandbox Access
- Step 2: Add PayPal Configuration
- Step 3: Create the Fastlane Order Model and Migration
- Step 4: Create the Payment Status Enum
- Step 5: Build the PayPal Fastlane Service
- Step 6: Create Form Requests
- Step 7: Add Controllers
- Step 8: Define Routes
- Step 9: Build Inertia Pages (TypeScript)
- Step 10: Configure Webhooks in PayPal
- Step 11: Test the Complete Flow
- Troubleshooting
- Conclusion
Overview
Tech Stack:
- Laravel 12
- Inertia.js v2
- React 19 + TypeScript (.tsx)
- PayPal Fastlane (sandbox)
- Laravel HTTP client for API calls and webhook verification
What you will implement:
- Email lookup to fetch Fastlane customer details
- Order creation using the PayPal Orders API
- Database persistence for orders and Fastlane profile data
- Webhook verification and status updates
- Inertia checkout and result pages
Prerequisites
- Laravel 12 app using the React starter kit (TypeScript)
- Basic Laravel knowledge (routes, controllers, migrations)
- A PayPal developer account with Fastlane sandbox access
What We Are Building
The user flow will be:
- Customer enters an email address on your checkout page.
- The frontend uses Fastlane identity to look up the customer and start authentication.
- Fastlane returns profile data and a payment token for returning customers.
- The frontend gets a payment token (Fastlane profile or payment component).
- Your server creates a PayPal order and stores the result.
- PayPal sends a webhook event that confirms the payment status.
Step 1: Prepare Your Fastlane Sandbox Access
- Log in to https://developer.paypal.com.
- Open Apps & Credentials and create a REST app for sandbox.
- Copy the Client ID and Secret.
- Ensure your PayPal account has Fastlane sandbox access enabled.
- Create a sandbox buyer account and note the email.
You already have a Fastlane sandbox account, so you can go straight to testing once the app is wired up.
Step 2: Add PayPal Configuration
Create a PayPal config file to keep all settings in one place.
File:
config/paypal.php
<?php
return [
'api_base' => env('PAYPAL_API_BASE', 'https://api-m.sandbox.paypal.com'),
'sdk_base_url' => env('PAYPAL_SDK_BASE_URL', 'https://www.paypal.com'),
'client_id' => env('PAYPAL_CLIENT_ID'),
'client_secret' => env('PAYPAL_CLIENT_SECRET'),
'merchant_id' => env('PAYPAL_MERCHANT_ID'),
'bn_code' => env('PAYPAL_BN_CODE'),
'domains' => array_filter(
array_map('trim', explode(',', env('PAYPAL_DOMAINS', 'localhost')))
),
'currency' => env('PAYPAL_CURRENCY', 'USD'),
'webhook_id' => env('PAYPAL_WEBHOOK_ID'),
];
Add these variables to your environment:
PAYPAL_API_BASE=https://api-m.sandbox.paypal.com
PAYPAL_SDK_BASE_URL=https://www.paypal.com
PAYPAL_CLIENT_ID=your-sandbox-client-id
PAYPAL_CLIENT_SECRET=your-sandbox-client-secret
PAYPAL_MERCHANT_ID=your-sandbox-merchant-id
PAYPAL_BN_CODE=your-partner-attribution-id
PAYPAL_DOMAINS=localhost
PAYPAL_CURRENCY=USD
PAYPAL_WEBHOOK_ID=your-sandbox-webhook-id
Set PAYPAL_DOMAINS to the hostnames where you will load the Fastlane SDK (comma-separated).
Step 3: Create the Fastlane Order Model and Migration
Create a model and migration for Fastlane orders.
php artisan make:model PayPalFastlaneOrder -m --no-interaction
File:
database/migrations/xxxx_xx_xx_create_paypal_fastlane_orders_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('paypal_fastlane_orders', function (Blueprint $table) {
$table->id();
$table->uuid('order_number')->unique();
$table->string('status');
$table->decimal('amount', 10, 2);
$table->string('currency', 3);
$table->string('description');
$table->string('customer_email');
$table->string('customer_first_name')->nullable();
$table->string('customer_last_name')->nullable();
$table->string('customer_phone')->nullable();
$table->json('customer_address')->nullable();
$table->string('paypal_order_id')->nullable();
$table->string('paypal_capture_id')->nullable();
$table->string('paypal_status')->nullable();
$table->string('paypal_event_id')->nullable();
$table->string('paypal_event_type')->nullable();
$table->json('paypal_webhook_payload')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('paypal_fastlane_orders');
}
};
Add the model with JSON casts.
File:
app/Models/PayPalFastlaneOrder.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PayPalFastlaneOrder extends Model
{
protected $fillable = [
'order_number',
'status',
'amount',
'currency',
'description',
'customer_email',
'customer_first_name',
'customer_last_name',
'customer_phone',
'customer_address',
'paypal_order_id',
'paypal_capture_id',
'paypal_status',
'paypal_event_id',
'paypal_event_type',
'paypal_webhook_payload',
];
protected function casts(): array
{
return [
'customer_address' => 'array',
'paypal_webhook_payload' => 'array',
];
}
}
Step 4: Create the Payment Status Enum
Use an enum for order status values.
File:
app/Enums/PayPalFastlaneOrderStatus.php
<?php
namespace App\Enums;
enum PayPalFastlaneOrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Failed = 'failed';
case Refunded = 'refunded';
}
Step 5: Build the PayPal Fastlane Service
This service generates the Fastlane SDK URL and client token, creates orders using the payment token, and verifies webhooks.
File:
app/Services/Payments/PayPalFastlane.php
<?php
namespace App\Services\Payments;
use App\Models\PayPalFastlaneOrder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class PayPalFastlane
{
public function getSdkUrl(): string
{
$query = http_build_query([
'client-id' => config('paypal.client_id'),
'components' => 'buttons,fastlane',
]);
return rtrim(config('paypal.sdk_base_url'), '/').'/sdk/js?'.$query;
}
/**
* @return string|null
*/
public function getClientToken(): ?string
{
$headers = array_filter([
'PayPal-Auth-Assertion' => $this->getAuthAssertionToken(),
]);
$response = Http::asForm()
->withBasicAuth(config('paypal.client_id'), config('paypal.client_secret'))
->withHeaders($headers)
->post(config('paypal.api_base').'/v1/oauth2/token', [
'grant_type' => 'client_credentials',
'response_type' => 'client_token',
'intent' => 'sdk_init',
'domains' => config('paypal.domains'),
]);
return $response->ok() ? $response->json('access_token') : null;
}
/**
* @param array<string, mixed> $paymentToken
* @param array<string, mixed>|null $shippingAddress
*
* @return array<string, mixed>
*/
public function createOrder(
PayPalFastlaneOrder $order,
array $paymentToken,
?array $shippingAddress
): array {
$token = $this->getAccessToken();
if (! $token) {
return [];
}
$body = [
'intent' => 'CAPTURE',
'payment_source' => [
'card' => [
'single_use_token' => $paymentToken['id'] ?? null,
],
],
'purchase_units' => [
[
'invoice_id' => $order->order_number,
'amount' => [
'currency_code' => $order->currency,
'value' => number_format((float) $order->amount, 2, '.', ''),
],
'description' => $order->description,
],
],
];
if ($shippingAddress) {
$body['purchase_units'][0]['shipping'] = [
'type' => 'SHIPPING',
'name' => data_get($shippingAddress, 'name.fullName')
? ['full_name' => data_get($shippingAddress, 'name.fullName')]
: null,
'company_name' => data_get($shippingAddress, 'companyName'),
'address' => [
'address_line_1' => data_get($shippingAddress, 'address.addressLine1'),
'address_line_2' => data_get($shippingAddress, 'address.addressLine2'),
'admin_area_2' => data_get($shippingAddress, 'address.adminArea2'),
'admin_area_1' => data_get($shippingAddress, 'address.adminArea1'),
'postal_code' => data_get($shippingAddress, 'address.postalCode'),
'country_code' => data_get($shippingAddress, 'address.countryCode'),
],
];
$countryCode = data_get($shippingAddress, 'phoneNumber.countryCode');
$nationalNumber = data_get($shippingAddress, 'phoneNumber.nationalNumber');
if ($countryCode && $nationalNumber) {
$body['purchase_units'][0]['shipping']['phone_number'] = [
'country_code' => $countryCode,
'national_number' => $nationalNumber,
];
}
}
$response = Http::asJson()
->withToken($token)
->post(config('paypal.api_base').'/v2/checkout/orders', $body);
return $response->ok() ? $response->json() : [];
}
public function verifyWebhook(Request $request): bool
{
$token = $this->getAccessToken();
if (! $token) {
return false;
}
$response = Http::asJson()
->withToken($token)
->post(config('paypal.api_base').'/v1/notifications/verify-webhook-signature', [
'auth_algo' => $request->header('paypal-auth-algo'),
'cert_url' => $request->header('paypal-cert-url'),
'transmission_id' => $request->header('paypal-transmission-id'),
'transmission_sig' => $request->header('paypal-transmission-sig'),
'transmission_time' => $request->header('paypal-transmission-time'),
'webhook_id' => config('paypal.webhook_id'),
'webhook_event' => $request->all(),
]);
return $response->ok()
&& data_get($response->json(), 'verification_status') === 'SUCCESS';
}
protected function getAccessToken(): ?string
{
$headers = array_filter([
'PayPal-Partner-Attribution-ID' => config('paypal.bn_code'),
'PayPal-Auth-Assertion' => $this->getAuthAssertionToken(),
]);
$response = Http::asForm()
->withBasicAuth(config('paypal.client_id'), config('paypal.client_secret'))
->withHeaders($headers)
->post(config('paypal.api_base').'/v1/oauth2/token', [
'grant_type' => 'client_credentials',
]);
if (! $response->ok()) {
return null;
}
return $response->json('access_token');
}
protected function getAuthAssertionToken(): ?string
{
$clientId = config('paypal.client_id');
$merchantId = config('paypal.merchant_id');
if (! $clientId || ! $merchantId) {
return null;
}
$header = base64_encode(json_encode(['alg' => 'none']));
$body = base64_encode(json_encode(['iss' => $clientId, 'payer_id' => $merchantId]));
return "{$header}.{$body}.";
}
}
The payment token comes from the Fastlane payment component or the Fastlane profile after authentication.
Step 6: Create Form Requests
Use Form Requests for validation.
php artisan make:request PayPalFastlaneTransactionRequest --no-interaction
File:
app/Http/Requests/PayPalFastlaneTransactionRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class PayPalFastlaneTransactionRequest extends FormRequest
{
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'email'],
'amount' => ['required', 'numeric', 'min:1', 'max:10000'],
'description' => ['required', 'string', 'max:255'],
'paymentToken' => ['required', 'array'],
'paymentToken.id' => ['required', 'string', 'max:500'],
'shippingAddress' => ['nullable', 'array'],
'shippingAddress.address' => ['nullable', 'array'],
'shippingAddress.name' => ['nullable', 'array'],
'shippingAddress.phoneNumber' => ['nullable', 'array'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'amount.min' => 'The minimum charge is 1.00.',
'amount.max' => 'The maximum charge is 10,000.00.',
'paymentToken.id.required' => 'The Fastlane payment token is required.',
];
}
}
Step 7: Add Controllers
Create controllers for the checkout page, transactions, and webhooks.
php artisan make:controller PayPalFastlaneCheckoutController --no-interaction
php artisan make:controller PayPalFastlaneTransactionController --no-interaction
php artisan make:controller PayPalFastlaneWebhookController --no-interaction
File:
app/Http/Controllers/PayPalFastlaneCheckoutController.php
<?php
namespace App\Http\Controllers;
use App\Services\Payments\PayPalFastlane;
use Inertia\Inertia;
use Inertia\Response;
class PayPalFastlaneCheckoutController extends Controller
{
public function show(PayPalFastlane $paypal): Response
{
return Inertia::render('checkout/paypal-fastlane-checkout', [
'sdkUrl' => $paypal->getSdkUrl(),
'clientToken' => $paypal->getClientToken(),
'currency' => config('paypal.currency'),
]);
}
public function result(): Response
{
return Inertia::render('checkout/paypal-fastlane-result');
}
}
File:
app/Http/Controllers/PayPalFastlaneTransactionController.php
<?php
namespace App\Http\Controllers;
use App\Enums\PayPalFastlaneOrderStatus;
use App\Http\Requests\PayPalFastlaneTransactionRequest;
use App\Models\PayPalFastlaneOrder;
use App\Services\Payments\PayPalFastlane;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
class PayPalFastlaneTransactionController extends Controller
{
public function store(PayPalFastlaneTransactionRequest $request, PayPalFastlane $paypal): JsonResponse
{
$data = $request->validated();
$shippingAddress = $data['shippingAddress'] ?? null;
$order = PayPalFastlaneOrder::query()->create([
'order_number' => (string) Str::uuid(),
'status' => PayPalFastlaneOrderStatus::Pending,
'amount' => $data['amount'],
'currency' => config('paypal.currency'),
'description' => $data['description'],
'customer_email' => $data['email'],
'customer_first_name' => data_get($shippingAddress, 'name.firstName'),
'customer_last_name' => data_get($shippingAddress, 'name.lastName'),
'customer_phone' => data_get($shippingAddress, 'phoneNumber.nationalNumber'),
'customer_address' => data_get($shippingAddress, 'address'),
]);
$paypalOrder = $paypal->createOrder($order, $data['paymentToken'], $shippingAddress);
$paypalOrderId = $paypalOrder['id'] ?? null;
if (! $paypalOrderId) {
return response()->json(['message' => 'Unable to create PayPal order'], 422);
}
$paypalStatus = $paypalOrder['status'] ?? null;
$capture = data_get($paypalOrder, 'purchase_units.0.payments.captures.0', []);
$order->update([
'paypal_order_id' => $paypalOrderId,
'paypal_capture_id' => $capture['id'] ?? null,
'paypal_status' => $paypalStatus,
'status' => $paypalStatus === 'COMPLETED'
? PayPalFastlaneOrderStatus::Paid
: PayPalFastlaneOrderStatus::Pending,
]);
return response()->json([
'orderNumber' => $order->order_number,
'status' => $order->status,
]);
}
}
File:
app/Http/Controllers/PayPalFastlaneWebhookController.php
<?php
namespace App\Http\Controllers;
use App\Enums\PayPalFastlaneOrderStatus;
use App\Models\PayPalFastlaneOrder;
use App\Services\Payments\PayPalFastlane;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class PayPalFastlaneWebhookController extends Controller
{
public function store(Request $request, PayPalFastlane $paypal): Response
{
if (! $paypal->verifyWebhook($request)) {
return response('Invalid webhook', 400);
}
$payload = $request->all();
$resource = $payload['resource'] ?? [];
$purchaseUnit = $resource['purchase_units'][0] ?? [];
$orderNumber = $purchaseUnit['invoice_id'] ?? null;
if (! $orderNumber) {
return response('Missing order number', 400);
}
$order = PayPalFastlaneOrder::query()->where('order_number', $orderNumber)->first();
if (! $order) {
return response('Order not found', 404);
}
$eventType = $payload['event_type'] ?? '';
$status = match ($eventType) {
'PAYMENT.CAPTURE.COMPLETED' => PayPalFastlaneOrderStatus::Paid,
'PAYMENT.CAPTURE.DENIED',
'PAYMENT.CAPTURE.REFUNDED' => PayPalFastlaneOrderStatus::Failed,
default => $order->status,
};
$order->update([
'status' => $status,
'paypal_event_id' => $payload['id'] ?? null,
'paypal_event_type' => $eventType,
'paypal_webhook_payload' => $payload,
]);
return response('OK', 200);
}
}
Step 8: Define Routes
Define routes for the checkout page, transactions, and webhooks.
File:
routes/web.php
<?php
use App\Http\Controllers\PayPalFastlaneCheckoutController;
use App\Http\Controllers\PayPalFastlaneTransactionController;
use App\Http\Controllers\PayPalFastlaneWebhookController;
use Illuminate\Support\Facades\Route;
Route::get('/checkout/paypal-fastlane', [PayPalFastlaneCheckoutController::class, 'show'])
->name('paypal.fastlane.checkout');
Route::get('/checkout/paypal-fastlane/result', [PayPalFastlaneCheckoutController::class, 'result'])
->name('paypal.fastlane.result');
Route::post('/api/paypal-fastlane/transactions', [PayPalFastlaneTransactionController::class, 'store'])
->name('paypal.fastlane.transactions.store');
Route::post('/webhooks/paypal-fastlane', [PayPalFastlaneWebhookController::class, 'store'])
->name('paypal.fastlane.webhook');
Exempt the webhook route from CSRF protection
PayPal webhooks post directly to your server, so they cannot include a CSRF token. In Laravel 12, add a CSRF exception in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'webhooks/paypal-fastlane',
]);
$middleware->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);
})
If you are using Wayfinder and the Vite plugin is not active, regenerate routes after adding routes:
php artisan wayfinder:generate --no-interaction
Step 9: Build Inertia Pages (TypeScript)
Create the checkout and result pages.
Checkout Page
This page uses axios for API calls. The Laravel starter kit already configures axios to include the CSRF token. The email field includes the Fastlane watermark, and the customer profile data returned by Fastlane is rendered after authentication.
File:
resources/js/pages/checkout/paypal-fastlane-checkout.tsx
import axios from 'axios';
import { Head, router } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
interface CheckoutProps {
sdkUrl: string;
clientToken: string | null;
currency: string;
}
interface FastlanePaymentToken {
id: string;
paymentSource?: {
card?: {
lastDigits?: string;
};
};
}
interface FastlaneShippingAddress {
name?: {
firstName?: string;
lastName?: string;
fullName?: string;
};
companyName?: string;
address?: {
addressLine1?: string;
addressLine2?: string;
adminArea2?: string;
adminArea1?: string;
postalCode?: string;
countryCode?: string;
};
phoneNumber?: {
countryCode?: string;
nationalNumber?: string;
};
}
interface FastlaneProfileData {
shippingAddress?: FastlaneShippingAddress;
card?: FastlanePaymentToken;
}
interface FastlaneAuthResponse {
authenticationState?: string;
profileData?: FastlaneProfileData;
}
interface FastlaneIdentity {
lookupCustomerByEmail: (email: string) => Promise<{ customerContextId?: string }>;
triggerAuthenticationFlow: (customerContextId: string) => Promise<FastlaneAuthResponse>;
}
interface FastlanePaymentComponent {
render: (container: string | HTMLElement) => void;
getPaymentToken: () => Promise<FastlanePaymentToken>;
}
interface FastlaneWatermarkComponent {
render: (container: string | HTMLElement) => void;
}
interface FastlaneInstance {
identity: FastlaneIdentity;
profile: {
showShippingAddressSelector: () => Promise<{
selectionChanged: boolean;
selectedAddress?: FastlaneShippingAddress;
}>;
};
FastlanePaymentComponent: () => Promise<FastlanePaymentComponent>;
FastlaneWatermarkComponent: (options: { includeAdditionalInfo: boolean }) => Promise<FastlaneWatermarkComponent>;
}
declare global {
interface Window {
paypal?: {
Fastlane?: (options: Record<string, unknown>) => Promise<FastlaneInstance>;
};
}
}
export default function PayPalFastlaneCheckout({ sdkUrl, clientToken, currency }: CheckoutProps) {
const [email, setEmail] = useState('buyer@example.com');
const [amount, setAmount] = useState('49.00');
const [description, setDescription] = useState('Laravel Fastlane checkout');
const [profileData, setProfileData] = useState<FastlaneProfileData | null>(null);
const [shippingAddress, setShippingAddress] = useState<FastlaneShippingAddress | null>(null);
const [paymentToken, setPaymentToken] = useState<FastlanePaymentToken | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSdkReady, setIsSdkReady] = useState(false);
const identityRef = useRef<FastlaneIdentity | null>(null);
const paymentComponentRef = useRef<FastlanePaymentComponent | null>(null);
useEffect(() => {
if (!sdkUrl || !clientToken) {
setError('Fastlane client token is missing.');
return;
}
const existingScript = document.querySelector(`script[src="${sdkUrl}"]`);
if (existingScript) {
setIsSdkReady(true);
return;
}
const script = document.createElement('script');
script.src = sdkUrl;
script.dataset.sdkClientToken = clientToken;
script.defer = true;
script.onload = () => setIsSdkReady(true);
script.onerror = () => setError('Unable to load PayPal Fastlane SDK.');
document.body.appendChild(script);
return () => {
script.remove();
};
}, [sdkUrl, clientToken]);
useEffect(() => {
if (!isSdkReady || !window.paypal?.Fastlane) {
return;
}
let isMounted = true;
const initFastlane = async () => {
const { identity, FastlanePaymentComponent, FastlaneWatermarkComponent } =
await window.paypal.Fastlane({
styles: {
root: {
backgroundColor: '#faf8f5',
},
},
});
const paymentComponent = await FastlanePaymentComponent();
const watermark = await FastlaneWatermarkComponent({ includeAdditionalInfo: true });
if (!isMounted) {
return;
}
identityRef.current = identity;
paymentComponentRef.current = paymentComponent;
watermark.render('#fastlane-watermark');
};
void initFastlane();
return () => {
isMounted = false;
};
}, [isSdkReady]);
const handleLookup = async () => {
if (!identityRef.current || !paymentComponentRef.current) {
setError('Fastlane is not ready yet.');
return;
}
setError(null);
setIsLoading(true);
try {
const { customerContextId } = await identityRef.current.lookupCustomerByEmail(email);
paymentComponentRef.current.render('#payment-component');
setProfileData(null);
setShippingAddress(null);
setPaymentToken(null);
if (customerContextId) {
const authResponse = await identityRef.current.triggerAuthenticationFlow(customerContextId);
if (authResponse.authenticationState === 'succeeded') {
setProfileData(authResponse.profileData ?? null);
setShippingAddress(authResponse.profileData?.shippingAddress ?? null);
setPaymentToken(authResponse.profileData?.card ?? null);
}
}
} catch (lookupError) {
setError('Fastlane lookup failed. Check your sandbox account and credentials.');
} finally {
setIsLoading(false);
}
};
const handleCharge = async () => {
if (!paymentComponentRef.current && !paymentToken) {
setError('Fastlane payment component is not ready.');
return;
}
setError(null);
setIsLoading(true);
try {
const token = paymentToken ?? (await paymentComponentRef.current?.getPaymentToken());
if (!token) {
setError('Unable to retrieve a payment token.');
return;
}
const response = await axios.post('/api/paypal-fastlane/transactions', {
email,
amount,
description,
paymentToken: token,
shippingAddress,
});
const payload = response.data as { orderNumber: string; status: string };
router.visit('/checkout/paypal-fastlane/result', {
data: {
order: payload.orderNumber,
status: payload.status,
},
});
} catch (chargeError) {
setError('Fastlane payment failed. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className="mx-auto max-w-2xl p-6">
<Head title="PayPal Fastlane Checkout" />
<h1 className="text-3xl font-semibold">PayPal Fastlane Checkout</h1>
<p className="mt-2 text-sm text-muted-foreground">
Fastlane will look up customer details after you enter an email address.
</p>
<div className="mt-6 flex flex-col gap-4">
<label className="flex flex-col gap-2">
<span className="text-sm font-medium">Buyer Email</span>
<div className="rounded-md border px-3 py-2">
<input
name="email"
type="email"
className="w-full border-none p-0 focus:outline-none focus:ring-0"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
<div id="fastlane-watermark" className="mt-2" />
</div>
</label>
<div className="flex flex-col gap-2 sm:flex-row">
<label className="flex flex-1 flex-col gap-2">
<span className="text-sm font-medium">Amount ({currency})</span>
<input
name="amount"
type="number"
step="0.01"
min="1"
className="rounded-md border px-3 py-2"
value={amount}
onChange={(event) => setAmount(event.target.value)}
required
/>
</label>
<label className="flex flex-1 flex-col gap-2">
<span className="text-sm font-medium">Description</span>
<input
name="description"
type="text"
className="rounded-md border px-3 py-2"
value={description}
onChange={(event) => setDescription(event.target.value)}
required
/>
</label>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
className="rounded-md bg-slate-900 px-4 py-2 text-white disabled:opacity-50"
onClick={handleLookup}
disabled={isLoading}
>
{isLoading ? 'Looking up...' : 'Continue'}
</button>
<button
type="button"
className="rounded-md bg-emerald-600 px-4 py-2 text-white disabled:opacity-50"
onClick={handleCharge}
disabled={isLoading}
>
{isLoading ? 'Processing...' : 'Checkout'}
</button>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="rounded-md border p-4">
<h2 className="text-sm font-semibold text-slate-900">Payment</h2>
<div id="payment-component" className="mt-4" />
</div>
{profileData && (
<div className="rounded-md border p-4">
<h2 className="text-sm font-semibold text-slate-900">Fastlane Customer Details</h2>
<div className="mt-3 grid gap-2 text-sm text-muted-foreground">
<p>Email: {email}</p>
<p>Name: {profileData.shippingAddress?.name?.fullName ?? 'Not provided'}</p>
<p>Phone: {profileData.shippingAddress?.phoneNumber?.nationalNumber ?? 'Not provided'}</p>
<p>Address: {profileData.shippingAddress?.address?.addressLine1 ?? 'Not provided'}</p>
</div>
<p className="mt-3 text-xs text-muted-foreground">
Fastlane authenticated: {paymentToken ? 'Yes' : 'No'}
</p>
</div>
)}
</div>
</div>
);
}
Result Page
File:
resources/js/pages/checkout/paypal-fastlane-result.tsx
import { Head, Link, usePage } from '@inertiajs/react';
interface ResultPageProps {
order?: string;
status?: string;
}
export default function PayPalFastlaneResult() {
const { props } = usePage<ResultPageProps>();
const status = props.status ?? 'pending';
const order = props.order ?? 'unknown';
const isSuccess = status === 'paid';
return (
<div className="mx-auto max-w-xl p-6">
<Head title={isSuccess ? 'Payment Complete' : 'Payment Pending'} />
<h1 className="text-3xl font-semibold">
{isSuccess ? 'Payment Complete' : 'Payment Pending'}
</h1>
<p className="mt-2 text-sm text-muted-foreground">Order: {order}</p>
<p className="mt-4">
{isSuccess
? 'Thanks for your purchase. Webhooks will confirm the payment shortly.'
: 'We are still waiting for confirmation. Refresh in a moment.'}
</p>
<Link href="/checkout/paypal-fastlane" className="mt-6 inline-flex text-blue-600 hover:underline">
Back to checkout
</Link>
</div>
);
}
Step 10: Configure Webhooks in PayPal
- Log in to the PayPal sandbox developer dashboard.
- Open Apps & Credentials and select your REST app.
- Add a Webhook URL, for example:
https://your-domain.test/webhooks/paypal-fastlane
-
Save the webhook and copy the Webhook ID into your environment.
-
Select these events:
- PAYMENT.CAPTURE.COMPLETED
- PAYMENT.CAPTURE.DENIED
- PAYMENT.CAPTURE.REFUNDED
Step 11: Test the Complete Flow
- Run migrations:
php artisan migrate --no-interaction
- Open the checkout page:
/checkout/paypal-fastlane
- Enter your sandbox buyer email and click Continue.
- If the account is recognized, complete the Fastlane authentication flow and verify profile details appear.
- Click Checkout to create the order and charge the customer.
- Confirm you are redirected to the result page.
- Verify the webhook updates the order status.
You can inspect the database:
select * from paypal_fastlane_orders order by id desc limit 5;
Troubleshooting
Fastlane lookup or authentication fails
- Verify PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, and PAYPAL_DOMAINS.
- Confirm the SDK script includes components=fastlane and uses the client token.
- Check the sandbox buyer email exists in your Fastlane account.
Order creation fails
- Ensure the payment token is present (Fastlane profile or payment component).
- Inspect the Orders API response for error details.
- Confirm the amount and currency match your sandbox settings.
Webhook not firing
- Confirm the webhook URL is reachable from the internet.
- Ensure the webhook route is excluded from CSRF.
- Verify the webhook ID matches your environment variable.
Conclusion
You now have a full PayPal Fastlane integration for Laravel 12 with React and TypeScript:
- Fastlane identity lookup and authentication
- Server-side order creation
- Persistent order storage
- Webhook verification for payment confirmation
From here, you can add features like shipping, taxes, discount codes, and receipt emails.