Code1180

Code1180

PayPal Venmo Checkout in Laravel (React + TypeScript)

PayPal Venmo Checkout in Laravel 12 (React + TypeScript)

PayPal Venmo Checkout uses the PayPal JS SDK with the Venmo funding source. In this tutorial you will build a complete end-to-end flow in a Laravel 12 React starter kit (TypeScript). You will create sandbox accounts, build server-side order creation and capture, store payments in your database, and verify webhooks.

This guide is written for junior developers and covers the full scope.

Table of Contents

  1. Overview
  2. Prerequisites
  3. What We Are Building
  4. Step 1: Create PayPal Sandbox Accounts (Including Venmo)
  5. Step 2: Create a PayPal REST App
  6. Step 3: Add PayPal Configuration
  7. Step 4: Create the Venmo Order Model and Migration
  8. Step 5: Create the Payment Status Enum
  9. Step 6: Build the PayPal Venmo Service
  10. Step 7: Create Form Requests
  11. Step 8: Add Controllers
  12. Step 9: Define Routes
  13. Step 10: Build Inertia Pages (TypeScript)
  14. Step 11: Configure Webhooks in PayPal
  15. Step 12: Test the Complete Flow
  16. Troubleshooting
  17. Conclusion

Overview

Tech Stack:

  • Laravel 12
  • Inertia.js v2
  • React 19 + TypeScript (.tsx)
  • PayPal JS SDK with Venmo funding
  • Laravel HTTP client for Orders API and webhook verification

What you will implement:

  • Checkout page with Venmo button
  • Server-side order creation and capture
  • Persistent order storage
  • Webhook verification and status updates
  • Result page after successful capture

Prerequisites

  • Laravel 12 app using the React starter kit (TypeScript)
  • Basic Laravel knowledge (routes, controllers, migrations)
  • A PayPal developer account

What We Are Building

The user flow will be:

  1. Customer opens the checkout page.
  2. The Venmo button is rendered using the PayPal JS SDK.
  3. The server creates a PayPal order and returns an order ID.
  4. Customer approves the payment in Venmo.
  5. The server captures the order and stores the result.
  6. PayPal sends a webhook event for final confirmation.

Step 1: Create PayPal Sandbox Accounts (Including Venmo)

  1. Go to https://developer.paypal.com.

  2. Log in or create a PayPal developer account.

  3. Open Dashboard -> Sandbox -> Accounts.

  4. Create two sandbox accounts:

    • Business account (merchant)
    • Personal account (buyer)
  5. For the business account:

    • Open details and copy the email and password.
    • This account receives payments.
  6. For the personal account:

    • Open details and copy the email and password.
    • Make sure the account is created in the United States (Venmo is US-only).
  7. Enable Venmo for the personal account:

    • In the sandbox account list, open the personal account.
    • Click Funding or Wallet settings.
    • Enable Venmo as a funding source.

If you do not see Venmo, delete the personal account and recreate it with a US region.

Step 2: Create a PayPal REST App

  1. Go to Apps & Credentials in the PayPal developer dashboard.
  2. Create a REST app under Sandbox.
  3. Copy the Client ID and Secret.

You will use the Client ID in the frontend and the Secret on the server.

Step 3: Add PayPal Configuration

Create a config file for PayPal settings.

File:

config/paypal.php
<?php

return [
    'api_base' => env('PAYPAL_API_BASE', 'https://api-m.sandbox.paypal.com'),
    'client_id' => env('PAYPAL_CLIENT_ID'),
    'client_secret' => env('PAYPAL_CLIENT_SECRET'),
    '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_CLIENT_ID=your-sandbox-client-id
PAYPAL_CLIENT_SECRET=your-sandbox-client-secret
PAYPAL_CURRENCY=USD
PAYPAL_WEBHOOK_ID=your-sandbox-webhook-id

Step 4: Create the Venmo Order Model and Migration

Create a model and migration for Venmo orders.

php artisan make:model PayPalVenmoOrder -m --no-interaction

File:

database/migrations/xxxx_xx_xx_create_paypal_venmo_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_venmo_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('paypal_order_id')->nullable();
            $table->string('paypal_capture_id')->nullable();
            $table->string('payer_email')->nullable();
            $table->string('payer_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_venmo_orders');
    }
};

Step 5: Create the Payment Status Enum

Use an enum for order status values.

File:

app/Enums/PayPalVenmoOrderStatus.php
<?php

namespace App\Enums;

enum PayPalVenmoOrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Failed = 'failed';
    case Refunded = 'refunded';
}

Step 6: Build the PayPal Venmo Service

This service creates and captures orders, and verifies webhook signatures.

File:

app/Services/Payments/PayPalVenmo.php
<?php

namespace App\Services\Payments;

use App\Models\PayPalVenmoOrder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

class PayPalVenmo
{
    /**
     * @return array<string, mixed>
     */
    public function createOrder(PayPalVenmoOrder $order): array
    {
        $token = $this->getAccessToken();
        if (! $token) {
            return [];
        }

        $response = Http::asJson()
            ->withToken($token)
            ->post(config('paypal.api_base').'/v2/checkout/orders', [
                'intent' => 'CAPTURE',
                'purchase_units' => [
                    [
                        'invoice_id' => $order->order_number,
                        'amount' => [
                            'currency_code' => $order->currency,
                            'value' => number_format((float) $order->amount, 2, '.', ''),
                        ],
                        'description' => $order->description,
                    ],
                ],
            ]);

        return $response->ok() ? $response->json() : [];
    }

    /**
     * @return array<string, mixed>
     */
    public function captureOrder(string $paypalOrderId): array
    {
        $token = $this->getAccessToken();
        if (! $token) {
            return [];
        }

        $response = Http::asJson()
            ->withToken($token)
            ->post(config('paypal.api_base')."/v2/checkout/orders/{$paypalOrderId}/capture");

        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
    {
        $response = Http::asForm()
            ->withBasicAuth(config('paypal.client_id'), config('paypal.client_secret'))
            ->post(config('paypal.api_base').'/v1/oauth2/token', [
                'grant_type' => 'client_credentials',
            ]);

        if (! $response->ok()) {
            return null;
        }

        return $response->json('access_token');
    }
}

Step 7: Create Form Requests

Use a Form Request for checkout validation.

php artisan make:request PayPalVenmoCheckoutRequest --no-interaction

File:

app/Http/Requests/PayPalVenmoCheckoutRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class PayPalVenmoCheckoutRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'amount' => ['required', 'numeric', 'min:1', 'max:10000'],
            'description' => ['required', 'string', 'max:255'],
        ];
    }

    public function messages(): array
    {
        return [
            'amount.min' => 'The minimum charge is 1.00.',
            'amount.max' => 'The maximum charge is 10,000.00.',
        ];
    }
}

Step 8: Add Controllers

Create controllers for the checkout page, API endpoints, and webhooks.

php artisan make:controller PayPalVenmoCheckoutController --no-interaction
php artisan make:controller PayPalVenmoOrderController --no-interaction
php artisan make:controller PayPalVenmoWebhookController --no-interaction

File:

app/Http/Controllers/PayPalVenmoCheckoutController.php
<?php

namespace App\Http\Controllers;

use Inertia\Inertia;
use Inertia\Response;

class PayPalVenmoCheckoutController extends Controller
{
    public function show(): Response
    {
        return Inertia::render('checkout/paypal-venmo-checkout', [
            'clientId' => config('paypal.client_id'),
            'currency' => config('paypal.currency'),
        ]);
    }

    public function result(): Response
    {
        return Inertia::render('checkout/paypal-venmo-result');
    }
}

File:

app/Http/Controllers/PayPalVenmoOrderController.php
<?php

namespace App\Http\Controllers;

use App\Enums\PayPalVenmoOrderStatus;
use App\Http\Requests\PayPalVenmoCheckoutRequest;
use App\Models\PayPalVenmoOrder;
use App\Services\Payments\PayPalVenmo;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;

class PayPalVenmoOrderController extends Controller
{
    public function store(PayPalVenmoCheckoutRequest $request, PayPalVenmo $paypal): JsonResponse
    {
        $data = $request->validated();

        $order = PayPalVenmoOrder::query()->create([
            'order_number' => (string) Str::uuid(),
            'status' => PayPalVenmoOrderStatus::Pending,
            'amount' => $data['amount'],
            'currency' => config('paypal.currency'),
            'description' => $data['description'],
        ]);

        $paypalOrder = $paypal->createOrder($order);
        $paypalOrderId = $paypalOrder['id'] ?? null;

        if (! $paypalOrderId) {
            return response()->json(['message' => 'Unable to create PayPal order'], 422);
        }

        $order->update([
            'paypal_order_id' => $paypalOrderId,
            'paypal_status' => $paypalOrder['status'] ?? null,
        ]);

        return response()->json([
            'paypalOrderId' => $paypalOrderId,
            'orderNumber' => $order->order_number,
        ]);
    }

    public function capture(string $paypalOrderId, PayPalVenmo $paypal): JsonResponse
    {
        $capture = $paypal->captureOrder($paypalOrderId);

        if (empty($capture)) {
            return response()->json(['message' => 'Capture failed'], 422);
        }

        $status = $capture['status'] ?? '';
        $payer = $capture['payer'] ?? [];
        $purchaseUnit = $capture['purchase_units'][0] ?? [];
        $payments = $purchaseUnit['payments']['captures'][0] ?? [];

        $orderNumber = $purchaseUnit['invoice_id'] ?? null;
        $order = $orderNumber
            ? PayPalVenmoOrder::query()->where('order_number', $orderNumber)->first()
            : null;

        if (! $order) {
            return response()->json(['message' => 'Order not found'], 404);
        }

        $order->update([
            'status' => $status === 'COMPLETED'
                ? PayPalVenmoOrderStatus::Paid
                : PayPalVenmoOrderStatus::Failed,
            'paypal_capture_id' => $payments['id'] ?? $order->paypal_capture_id,
            'paypal_status' => $status,
            'payer_email' => $payer['email_address'] ?? $order->payer_email,
            'payer_id' => $payer['payer_id'] ?? $order->payer_id,
        ]);

        return response()->json([
            'orderNumber' => $order->order_number,
            'status' => $order->status,
        ]);
    }
}

File:

app/Http/Controllers/PayPalVenmoWebhookController.php
<?php

namespace App\Http\Controllers;

use App\Enums\PayPalVenmoOrderStatus;
use App\Models\PayPalVenmoOrder;
use App\Services\Payments\PayPalVenmo;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class PayPalVenmoWebhookController extends Controller
{
    public function store(Request $request, PayPalVenmo $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 = PayPalVenmoOrder::query()->where('order_number', $orderNumber)->first();
        if (! $order) {
            return response('Order not found', 404);
        }

        $eventType = $payload['event_type'] ?? '';
        $status = match ($eventType) {
            'CHECKOUT.ORDER.APPROVED',
            'PAYMENT.CAPTURE.COMPLETED' => PayPalVenmoOrderStatus::Paid,
            'PAYMENT.CAPTURE.DENIED',
            'PAYMENT.CAPTURE.REFUNDED' => PayPalVenmoOrderStatus::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 9: Define Routes

Define routes for the checkout page, order creation, capture, and webhooks.

File:

routes/web.php
<?php

use App\Http\Controllers\PayPalVenmoCheckoutController;
use App\Http\Controllers\PayPalVenmoOrderController;
use App\Http\Controllers\PayPalVenmoWebhookController;
use Illuminate\Support\Facades\Route;

Route::get('/checkout/paypal-venmo', [PayPalVenmoCheckoutController::class, 'show'])
    ->name('paypal.venmo.checkout');

Route::get('/checkout/paypal-venmo/result', [PayPalVenmoCheckoutController::class, 'result'])
    ->name('paypal.venmo.result');

Route::post('/api/paypal-venmo/orders', [PayPalVenmoOrderController::class, 'store'])
    ->name('paypal.venmo.orders.store');

Route::post('/api/paypal-venmo/orders/{paypalOrderId}/capture', [PayPalVenmoOrderController::class, 'capture'])
    ->name('paypal.venmo.orders.capture');

Route::post('/webhooks/paypal-venmo', [PayPalVenmoWebhookController::class, 'store'])
    ->name('paypal.venmo.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-venmo',
    ]);

    $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 10: Build Inertia Pages (TypeScript)

Create the checkout and result pages.

Checkout Page

File:

resources/js/pages/checkout/paypal-venmo-checkout.tsx
import { Head, router } from '@inertiajs/react';
import { useEffect, useMemo, useRef, useState } from 'react';

declare global {
    interface Window {
        paypal?: {
            Buttons: (options: {
                createOrder: () => Promise<string>;
                onApprove: (data: { orderID: string }) => Promise<void>;
                onError: (error: unknown) => void;
            }) => { render: (container: string | HTMLElement) => void };
        };
    }
}

interface CheckoutProps {
    clientId: string;
    currency: string;
}

interface CheckoutState {
    amount: string;
    description: string;
}

function getCsrfToken(): string {
    const token = document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null;
    return token?.content ?? '';
}

export default function PayPalVenmoCheckout({ clientId, currency }: CheckoutProps) {
    const [state, setState] = useState<CheckoutState>({ amount: '49.00', description: 'Laravel course' });
    const [error, setError] = useState<string | null>(null);
    const [isReady, setIsReady] = useState(false);
    const buttonRef = useRef<HTMLDivElement>(null);

    const sdkUrl = useMemo(() => {
        const params = new URLSearchParams({
            'client-id': clientId,
            currency,
            intent: 'capture',
            'enable-funding': 'venmo',
        });

        return `https://www.paypal.com/sdk/js?${params.toString()}`;
    }, [clientId, currency]);

    useEffect(() => {
        let script: HTMLScriptElement | null = document.querySelector(`script[src="${sdkUrl}"]`);

        if (!script) {
            script = document.createElement('script');
            script.src = sdkUrl;
            script.async = true;
            script.onload = () => setIsReady(true);
            script.onerror = () => setError('Unable to load the PayPal SDK.');
            document.body.appendChild(script);
        } else {
            setIsReady(true);
        }

        return () => {
            if (script && script.parentNode) {
                script.parentNode.removeChild(script);
            }
        };
    }, [sdkUrl]);

    useEffect(() => {
        if (!isReady || !buttonRef.current || !window.paypal) {
            return;
        }

        window.paypal.Buttons({
            createOrder: async () => {
                setError(null);

                const response = await fetch('/api/paypal-venmo/orders', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRF-TOKEN': getCsrfToken(),
                    },
                    body: JSON.stringify(state),
                });

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

                const data = await response.json();
                return data.paypalOrderId as string;
            },
            onApprove: async (data) => {
                const response = await fetch(`/api/paypal-venmo/orders/${data.orderID}/capture`, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRF-TOKEN': getCsrfToken(),
                    },
                });

                if (!response.ok) {
                    setError('Payment capture failed.');
                    return;
                }

                const payload = await response.json();
                router.visit('/checkout/paypal-venmo/result', {
                    data: {
                        order: payload.orderNumber,
                        status: payload.status,
                    },
                });
            },
            onError: () => {
                setError('Something went wrong while processing Venmo payment.');
            },
        }).render(buttonRef.current);
    }, [isReady, state]);

    return (
        <div className="mx-auto max-w-xl p-6">
            <Head title="PayPal Venmo Checkout" />
            <h1 className="text-3xl font-semibold">PayPal Venmo Checkout</h1>
            <p className="mt-2 text-sm text-muted-foreground">
                Use Venmo to complete this payment.
            </p>

            <div className="mt-6 flex flex-col gap-4">
                <label className="flex 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={state.amount}
                        onChange={(event) => setState((prev) => ({ ...prev, amount: event.target.value }))}
                        required
                    />
                </label>

                <label className="flex 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={state.description}
                        onChange={(event) => setState((prev) => ({ ...prev, description: event.target.value }))}
                        required
                    />
                </label>

                {error && <p className="text-sm text-red-600">{error}</p>}

                <div className="rounded-md border p-4">
                    <p className="text-sm text-muted-foreground">Venmo button</p>
                    <div ref={buttonRef} className="mt-4" />
                </div>
            </div>
        </div>
    );
}

Result Page

File:

resources/js/pages/checkout/paypal-venmo-result.tsx
import { Head, Link, usePage } from '@inertiajs/react';

interface ResultPageProps {
    order?: string;
    status?: string;
}

export default function PayPalVenmoResult() {
    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-venmo" className="mt-6 inline-flex text-blue-600 hover:underline">
                Back to checkout
            </Link>
        </div>
    );
}

Step 11: Configure Webhooks in PayPal

  1. Log in to the PayPal sandbox developer dashboard.
  2. Open Apps & Credentials and select your REST app.
  3. Add a Webhook URL, for example:
https://your-domain.test/webhooks/paypal-venmo
  1. Save the webhook and copy the Webhook ID into your environment.

  2. Select these events:

    • CHECKOUT.ORDER.APPROVED
    • PAYMENT.CAPTURE.COMPLETED
    • PAYMENT.CAPTURE.DENIED
    • PAYMENT.CAPTURE.REFUNDED

Step 12: Test the Complete Flow

  1. Run migrations:
php artisan migrate --no-interaction
  1. Open the checkout page:
/checkout/paypal-venmo
  1. Click the Venmo button. You will be redirected to the PayPal sandbox Venmo flow.
  2. Log in with your personal sandbox account and approve payment.
  3. You should return to the result page.
  4. Webhooks will mark the order as paid.

You can inspect the database:

select * from paypal_venmo_orders order by id desc limit 5;

Troubleshooting

Venmo button does not appear

  • Ensure the buyer sandbox account is US-based.
  • Make sure the PayPal JS SDK includes enable-funding=venmo.
  • Verify you are using a sandbox Client ID.

Order creation fails

  • Verify PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET.
  • Check the network response from the create order endpoint.
  • Confirm your API base URL is the sandbox endpoint.

Capture fails

  • Verify the order ID is valid.
  • Confirm the order status is APPROVED.
  • Inspect the capture response in your network tab.

Webhook not firing

  • Confirm the webhook URL is reachable from the internet.
  • Verify the webhook ID matches the one in your environment.
  • Ensure the webhook route is excluded from CSRF.

Conclusion

You now have a full PayPal Venmo Checkout flow in Laravel 12 with React and TypeScript:

  • Venmo button with PayPal JS SDK
  • Server-side order creation and capture
  • Persistent order storage
  • Webhook verification for final confirmation

From here you can add taxes, shipping, subscriptions, and receipt emails.