Payments
PayPal

PayPal Integration

Complete guide for integrating PayPal payments with BareCommerce. Orders are created automatically when PayPal webhooks arrive.

Why PayPal?

  • Global Reach — PayPal available in 200+ countries
  • Trust — 429+ million active users
  • Multiple Methods — PayPal Wallet, credit cards, local payment methods
  • Buyer Protection — Built-in dispute resolution
  • Easy Setup — Simple API and webhook configuration

Prerequisites

How It Works

1. Collect order information
2. Create PayPal order with order data in metadata
3. Customer approves on PayPal
4. Capture the payment
5. PayPal sends webhook to BareCommerce
6. ✅ BareCommerce creates order automatically

Setup

1. Install Dependencies

npm install @paypal/checkout-server-sdk

2. Environment Variables

# PayPal
PAYPAL_MODE=sandbox  # or production
PAYPAL_CLIENT_ID=your_client_id
PAYPAL_CLIENT_SECRET=your_client_secret
PAYPAL_WEBHOOK_ID=your_webhook_id

3. PayPal Webhook Setup

  1. Go to PayPal Developer Dashboard (opens in a new tab)
  2. Select Sandbox or Production
  3. Go to Apps & Credentials
  4. Select your app
  5. Go to Webhooks
  6. Click Create Webhook
  7. Webhook URL: https://yourdomain.com/api/webhooks/paypal
  8. Select events to receive:
    • PAYMENT.CAPTURE.COMPLETED
    • PAYMENT.CAPTURE.DENIED
    • PAYMENT.CAPTURE.REFUNDED
  9. Click Create Webhook
  10. Copy the Webhook ID to PAYPAL_WEBHOOK_ID

Backend: Create PayPal Order Endpoint

Create an endpoint that generates PayPal orders with order data in the custom_id:

// pages/api/paypal/create-order.ts
import { NextRequest, NextResponse } from 'next/server';
import * as paypal from '@paypal/checkout-server-sdk';
 
const client = new paypal.core.PayPalHttpClient(
  new paypal.core.SandboxEnvironment(
    process.env.PAYPAL_CLIENT_ID,
    process.env.PAYPAL_CLIENT_SECRET
  )
);
 
export async function POST(request: NextRequest) {
  try {
    const {
      storeId,
      customerId,
      items,
      subtotal,
      tax,
      shipping,
      discount,
      total,
      currency,
      shippingAddress
    } = await request.json();
 
    if (!storeId || !customerId || !items?.length || !total) {
      return NextResponse.json(
        { error: 'Missing required fields' },
        { status: 400 }
      );
    }
 
    // Prepare order metadata for BareCommerce
    // This will be sent via webhook
    const customIdData = {
      store_id: storeId,
      customer_id: customerId,
      items: items,
      subtotal,
      tax,
      shipping,
      discount
    };
 
    // Create PayPal order
    const createRequest = new paypal.orders.OrdersCreateRequest();
    createRequest.prefer('return=representation');
    createRequest.requestBody({
      intent: 'CAPTURE',
      purchase_units: [
        {
          reference_id: 'BARECOMMERCE_ORDER',
          amount: {
            currency_code: currency,
            value: total.toString(),
            breakdown: {
              item_total: {
                currency_code: currency,
                value: subtotal.toString()
              },
              tax_total: {
                currency_code: currency,
                value: tax.toString()
              },
              shipping: {
                currency_code: currency,
                value: shipping.toString()
              },
              discount: {
                currency_code: currency,
                value: discount.toString()
              }
            }
          },
          items: items.map((item: any) => ({
            name: item.productId,
            quantity: item.quantity.toString(),
            unit_amount: {
              currency_code: currency,
              value: item.unitPrice.toString()
            }
          })),
          shipping: {
            name: {
              full_name: shippingAddress?.name || 'Customer'
            },
            address: {
              address_line_1: shippingAddress?.address1,
              address_line_2: shippingAddress?.address2,
              admin_area_2: shippingAddress?.city,
              admin_area_1: shippingAddress?.region,
              postal_code: shippingAddress?.postalCode,
              country_code: shippingAddress?.country
            }
          },
          // Store order metadata in custom_id for webhook
          custom_id: JSON.stringify(customIdData)
        }
      ]
    });
 
    const response = await client.execute(createRequest);
 
    return NextResponse.json({
      id: response.result.id,
      status: response.result.status
    });
  } catch (error: any) {
    console.error('PayPal order creation failed:', error);
    return NextResponse.json(
      { error: error.message },
      { status: 500 }
    );
  }
}

Frontend: PayPal Checkout

// components/PayPalCheckout.tsx
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
 
declare global {
  namespace window {
    var paypal: any;
  }
}
 
export function PayPalCheckout({
  cartItems,
  customerInfo,
  shippingAddress,
  onSuccess
}: {
  cartItems: CartItem[];
  customerInfo: { customerId: string; storeId: string };
  shippingAddress: Address;
  onSuccess: (orderId: string) => void;
}) {
  const router = useRouter();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
 
  // Calculate totals
  const subtotal = cartItems.reduce(
    (sum, item) => sum + parseFloat(item.price) * item.quantity,
    0
  );
  const tax = subtotal * 0.08;
  const shipping = 10.0;
  const discount = 0;
  const total = subtotal + tax + shipping - discount;
 
  // Load PayPal script
  useEffect(() => {
    const script = document.createElement('script');
    script.src = `https://www.paypal.com/sdk/js?client-id=${process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID}`;
    script.async = true;
    script.onload = initPayPal;
    document.body.appendChild(script);
 
    return () => {
      document.body.removeChild(script);
    };
  }, []);
 
  const initPayPal = () => {
    if (!window.paypal) return;
 
    window.paypal.Buttons({
      createOrder: async () => {
        console.log('Creating PayPal order...');
        try {
          // Step 1: Create PayPal order with order metadata
          const response = await fetch('/api/paypal/create-order', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              storeId: customerInfo.storeId,
              customerId: customerInfo.customerId,
              items: cartItems.map(item => ({
                productId: item.productId,
                quantity: item.quantity,
                unitPrice: item.price
              })),
              subtotal: subtotal.toFixed(2),
              tax: tax.toFixed(2),
              shipping: shipping.toFixed(2),
              discount: discount.toFixed(2),
              total: total.toFixed(2),
              currency: 'USD',
              shippingAddress
            })
          });
 
          if (!response.ok) {
            throw new Error('Failed to create PayPal order');
          }
 
          const { id } = await response.json();
          console.log('✓ PayPal order created:', id);
          return id;
        } catch (err: any) {
          setError(err.message);
          console.error('Order creation failed:', err);
          throw err;
        }
      },
 
      onApprove: async (data: any) => {
        console.log('Customer approved, capturing payment...');
        try {
          // Step 2: Capture the payment
          const response = await fetch('/api/paypal/capture-order', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              orderID: data.orderID
            })
          });
 
          if (!response.ok) {
            throw new Error('Failed to capture payment');
          }
 
          console.log('✓ Payment captured');
 
          // Step 3: Wait for webhook to create order
          console.log('Waiting for order to be created via webhook...');
          await new Promise(resolve => setTimeout(resolve, 2000));
 
          // Step 4: Order is ready
          onSuccess(data.orderID);
        } catch (err: any) {
          setError(err.message);
          console.error('Capture failed:', err);
        }
      },
 
      onError: (err: any) => {
        setError('Payment failed. Please try again.');
        console.error('PayPal error:', err);
      },
 
      onCancel: () => {
        setError('Payment cancelled.');
        console.log('Payment cancelled');
      }
    }).render('#paypal-button-container');
  };
 
  return (
    <div>
      {error && (
        <div className="mb-4 p-4 bg-red-50 text-red-700 rounded">
          {error}
        </div>
      )}
 
      <div className="mb-4 p-4 bg-gray-50 rounded">
        <div className="flex justify-between mb-2">
          <span>Subtotal:</span>
          <span>${subtotal.toFixed(2)}</span>
        </div>
        <div className="flex justify-between mb-2">
          <span>Tax:</span>
          <span>${tax.toFixed(2)}</span>
        </div>
        <div className="flex justify-between mb-4">
          <span>Shipping:</span>
          <span>${shipping.toFixed(2)}</span>
        </div>
        <div className="flex justify-between font-bold text-lg border-t pt-2">
          <span>Total:</span>
          <span>${total.toFixed(2)}</span>
        </div>
      </div>
 
      <div id="paypal-button-container" />
    </div>
  );
}

Backend: Capture and Webhook Handler

Capture Endpoint

// pages/api/paypal/capture-order.ts
import { NextRequest, NextResponse } from 'next/server';
import * as paypal from '@paypal/checkout-server-sdk';
 
const client = new paypal.core.PayPalHttpClient(
  new paypal.core.SandboxEnvironment(
    process.env.PAYPAL_CLIENT_ID,
    process.env.PAYPAL_CLIENT_SECRET
  )
);
 
export async function POST(request: NextRequest) {
  try {
    const { orderID } = await request.json();
 
    if (!orderID) {
      return NextResponse.json(
        { error: 'orderID is required' },
        { status: 400 }
      );
    }
 
    // Capture the payment
    const captureRequest = new paypal.orders.OrdersCaptureRequest(orderID);
    captureRequest.requestBody({});
 
    const response = await client.execute(captureRequest);
 
    if (response.result.status === 'COMPLETED') {
      console.log('✓ Payment captured:', response.result.id);
      return NextResponse.json({
        status: 'COMPLETED',
        orderID: response.result.id
      });
    } else {
      throw new Error(`Payment not completed. Status: ${response.result.status}`);
    }
  } catch (error: any) {
    console.error('Capture failed:', error);
    return NextResponse.json(
      { error: error.message },
      { status: 500 }
    );
  }
}

Webhook Handler

// pages/api/webhooks/paypal.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
 
const WEBHOOK_ID = process.env.PAYPAL_WEBHOOK_ID || '';
const CLIENT_ID = process.env.PAYPAL_CLIENT_ID || '';
const CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET || '';
 
/**
 * Verify PayPal webhook signature via PayPal API
 */
async function verifyPayPalWebhook(
  transmissionId: string,
  transmissionTime: string,
  certUrl: string,
  authAlgo: string,
  transmissionSig: string,
  webhookBody: string
): Promise<boolean> {
  try {
    // Get access token
    const tokenResponse = await fetch('https://api.sandbox.paypal.com/v1/oauth2/token', {
      method: 'POST',
      headers: {
        Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: 'grant_type=client_credentials'
    });
 
    const tokenData = await tokenResponse.json();
    const accessToken = tokenData.access_token;
 
    // Verify signature with PayPal
    const verifyResponse = await fetch(
      'https://api.sandbox.paypal.com/v1/notifications/verify-webhook-signature',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${accessToken}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          transmission_id: transmissionId,
          transmission_time: transmissionTime,
          cert_url: certUrl,
          auth_algo: authAlgo,
          transmission_sig: transmissionSig,
          webhook_id: WEBHOOK_ID,
          webhook_event: JSON.parse(webhookBody)
        })
      }
    );
 
    const verifyData = await verifyResponse.json();
    return verifyData.verification_status === 'SUCCESS';
  } catch (error) {
    console.error('PayPal verification error:', error);
    return false;
  }
}
 
export async function POST(request: NextRequest) {
  try {
    const transmissionId = request.headers.get('paypal-transmission-id') || '';
    const transmissionTime = request.headers.get('paypal-transmission-time') || '';
    const certUrl = request.headers.get('paypal-cert-url') || '';
    const authAlgo = request.headers.get('paypal-auth-algo') || '';
    const transmissionSig = request.headers.get('paypal-transmission-sig') || '';
 
    const webhookBody = await request.text();
 
    // Verify webhook signature
    const isValid = await verifyPayPalWebhook(
      transmissionId,
      transmissionTime,
      certUrl,
      authAlgo,
      transmissionSig,
      webhookBody
    );
 
    if (!isValid) {
      console.error('Invalid PayPal webhook signature');
      return NextResponse.json(
        { error: 'Invalid signature' },
        { status: 401 }
      );
    }
 
    // Parse event
    const event = JSON.parse(webhookBody);
    console.log(`[PayPal] Received event: ${event.event_type} (${event.id})`);
 
    try {
      switch (event.event_type) {
        case 'PAYMENT.CAPTURE.COMPLETED': {
          const capture = event.resource;
          console.log(`✓ Payment completed: ${capture.id}`);
          
          // Extract custom_id from purchase units
          const customIdStr = capture.supplementary_data?.related_ids?.order_reference_id;
          if (!customIdStr) {
            console.warn('No custom_id in webhook');
            return NextResponse.json({ received: true });
          }
 
          const orderData = JSON.parse(customIdStr);
          console.log('Order data from webhook:', orderData);
 
          // IMPORTANT: BareCommerce order is created here from webhook
          // You would normally call a BareCommerce ingestion function
          // For now, just log that we received it
          
          break;
        }
 
        case 'PAYMENT.CAPTURE.DENIED': {
          const capture = event.resource;
          console.log(`✗ Payment denied: ${capture.id}`);
          console.log(`Reason: ${capture.status_details?.reason}`);
          break;
        }
 
        case 'PAYMENT.CAPTURE.REFUNDED': {
          const capture = event.resource;
          console.log(`↩ Payment refunded: ${capture.id}`);
          break;
        }
 
        default:
          console.log(`Unhandled event type: ${event.event_type}`);
      }
 
      return NextResponse.json({ received: true });
    } catch (error) {
      console.error('Event processing error:', error);
      return NextResponse.json(
        { error: 'Event processing failed' },
        { status: 500 }
      );
    }
  } catch (error: any) {
    console.error('Webhook handler error:', error);
    return NextResponse.json(
      { error: 'Webhook handler failed' },
      { status: 500 }
    );
  }
}
 
export async function GET() {
  return NextResponse.json(
    { error: 'Method not allowed' },
    { status: 405 }
  );
}

Testing

Test Accounts

Use these test credentials in Sandbox:

  • Personal Account (Buyer): Provided by PayPal Dashboard
  • Business Account (Seller): Provided by PayPal Dashboard

Test End-to-End

  1. Set PAYPAL_MODE=sandbox in .env
  2. Start your app: npm run dev
  3. Go to checkout page
  4. Click "Pay with PayPal"
  5. Log in with PayPal sandbox account
  6. Approve the payment
  7. Watch logs for webhook delivery
  8. Verify order was created

Local Testing with ngrok

# 1. Start ngrok
ngrok http 3000
 
# 2. Update webhook URL in PayPal Dashboard with ngrok URL
 
# 3. Run your app
npm run dev
 
# 4. Test payment flow

Production Checklist

  • Switch to live credentials (PAYPAL_MODE=production)
  • Update webhook URL to production domain
  • Test with small real payment
  • Webhook signature verification enabled
  • Error handling for failed payments
  • Refund handling implemented
  • Monitoring/alerting configured
  • Payment confirmation emails working
  • SSL certificate valid

Related Guides