Payment Integrations

PayPal Payment Integration: Production Implementation Guide

Integrate PayPal with Orders API and Smart Buttons. Covers webhook setup, subscription payments, and real error handling patterns from production.

Khalid Aboubakr
24 min read
PaypalPayment GatewayReactNodejsCheckoutSubscriptionsDisputesSmart ButtonsOrders Api

Table of Contents

  1. PayPal's Place in Payment Strategy
  2. Setting Up PayPal Correctly
  3. Orders API v2 Implementation
  4. Smart Payment Buttons in React
  5. Webhook Configuration
  6. Subscription Billing
  7. Dispute and Refund Handling
  8. Common Integration Pitfalls

PayPal's Place in Payment Strategy

PayPal isn't just another payment method—it's a conversion optimizer. In my experience with e-commerce platforms:

  • 15-30% of customers prefer PayPal over entering card details
  • Mobile conversion improves significantly with PayPal's one-tap checkout
  • Buyer protection perception builds trust for first-time customers

But PayPal also has challenges: higher fees, complex dispute process, and an API that's evolved over decades.

Setting Up PayPal Correctly

Environment Configuration

// config/paypal.ts import checkoutNodeJssdk from '@paypal/checkout-server-sdk'; function environment() { const clientId = process.env.PAYPAL_CLIENT_ID!; const clientSecret = process.env.PAYPAL_CLIENT_SECRET!; return process.env.NODE_ENV === 'production' ? new checkoutNodeJssdk.core.LiveEnvironment(clientId, clientSecret) : new checkoutNodeJssdk.core.SandboxEnvironment(clientId, clientSecret); } export const paypalClient = new checkoutNodeJssdk.core.PayPalHttpClient(environment()); export const PAYPAL_CONFIG = { clientId: process.env.PAYPAL_CLIENT_ID!, currency: 'USD', intent: 'CAPTURE' as const, };

Orders API v2 Implementation

Creating Orders

// services/paypal/OrderService.ts import checkoutNodeJssdk from '@paypal/checkout-server-sdk'; import { paypalClient } from '@/config/paypal'; interface CreatePayPalOrderParams { orderId: string; amount: number; currency: string; items: Array<{ name: string; sku: string; quantity: number; unitAmount: number; }>; shipping?: { name: string; address: { addressLine1: string; addressLine2?: string; city: string; state: string; postalCode: string; countryCode: string; }; }; } export class PayPalOrderService { async createOrder(params: CreatePayPalOrderParams): Promise<{ paypalOrderId: string; approvalUrl: string; }> { const { orderId, amount, currency, items, shipping } = params; // Validate order const order = await this.orderRepo.findById(orderId); if (!order || Math.round(order.total * 100) !== Math.round(amount * 100)) { throw new PaymentError('INVALID_ORDER', 'Order validation failed'); } const request = new checkoutNodeJssdk.orders.OrdersCreateRequest(); request.prefer('return=representation'); request.requestBody({ intent: 'CAPTURE', purchase_units: [ { reference_id: orderId, description: `Order #${order.orderNumber}`, custom_id: orderId, amount: { currency_code: currency, value: amount.toFixed(2), breakdown: { item_total: { currency_code: currency, value: items .reduce((sum, item) => sum + item.unitAmount * item.quantity, 0) .toFixed(2), }, shipping: { currency_code: currency, value: (order.shippingAmount || 0).toFixed(2), }, tax_total: { currency_code: currency, value: (order.taxAmount || 0).toFixed(2), }, }, }, items: items.map((item) => ({ name: item.name.substring(0, 127), // PayPal limit sku: item.sku, quantity: item.quantity.toString(), unit_amount: { currency_code: currency, value: item.unitAmount.toFixed(2), }, category: 'PHYSICAL_GOODS', })), shipping: shipping ? { name: { full_name: shipping.name, }, address: { address_line_1: shipping.address.addressLine1, address_line_2: shipping.address.addressLine2, admin_area_2: shipping.address.city, admin_area_1: shipping.address.state, postal_code: shipping.address.postalCode, country_code: shipping.address.countryCode, }, } : undefined, }, ], application_context: { brand_name: 'Your Company', landing_page: 'NO_PREFERENCE', user_action: 'PAY_NOW', return_url: `${process.env.APP_URL}/checkout/paypal/success?orderId=${orderId}`, cancel_url: `${process.env.APP_URL}/checkout/paypal/cancel?orderId=${orderId}`, shipping_preference: shipping ? 'SET_PROVIDED_ADDRESS' : 'NO_SHIPPING', }, }); try { const response = await paypalClient.execute(request); const paypalOrder = response.result; // Store PayPal order reference await this.paymentRepo.create({ orderId, paypalOrderId: paypalOrder.id, status: 'created', amount, currency, }); const approvalUrl = paypalOrder.links.find( (link: any) => link.rel === 'approve' )?.href; return { paypalOrderId: paypalOrder.id, approvalUrl: approvalUrl!, }; } catch (error) { console.error('PayPal order creation failed:', error); throw new PaymentError('ORDER_CREATION_FAILED', 'Could not create PayPal order'); } } async captureOrder(paypalOrderId: string): Promise<{ captureId: string; status: string; }> { const request = new checkoutNodeJssdk.orders.OrdersCaptureRequest(paypalOrderId); request.requestBody({}); try { const response = await paypalClient.execute(request); const capture = response.result; const captureId = capture.purchase_units[0].payments.captures[0].id; const orderId = capture.purchase_units[0].reference_id; await this.paymentRepo.updateByPaypalOrderId(paypalOrderId, { status: 'captured', captureId, capturedAt: new Date(), }); return { captureId, status: capture.status, }; } catch (error: any) { // Handle specific PayPal errors if (error.statusCode === 422) { const issue = error.result?.details?.[0]?.issue; if (issue === 'ORDER_ALREADY_CAPTURED') { // Idempotent - order was already captured const existingPayment = await this.paymentRepo.findByPaypalOrderId(paypalOrderId); return { captureId: existingPayment!.captureId!, status: 'COMPLETED', }; } } throw error; } } }

Smart Payment Buttons in React

// components/payment/PayPalButton.tsx import { PayPalScriptProvider, PayPalButtons } from '@paypal/react-paypal-js'; interface PayPalButtonProps { orderId: string; amount: number; currency: string; onSuccess: (details: any) => void; onError: (error: any) => void; onCancel: () => void; } export function PayPalPaymentButton({ orderId, amount, currency, onSuccess, onError, onCancel, }: PayPalButtonProps) { return ( <PayPalScriptProvider options={{ clientId: process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID!, currency, intent: 'capture', components: 'buttons', disableFunding: 'credit,card', // Only show PayPal button }} > <PayPalButtons style={{ layout: 'vertical', color: 'gold', shape: 'rect', label: 'paypal', height: 48, }} createOrder={async () => { try { const response = await fetch('/api/paypal/create-order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orderId }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.message || 'Failed to create order'); } return data.paypalOrderId; } catch (error) { onError(error); throw error; } }} onApprove={async (data) => { try { const response = await fetch('/api/paypal/capture-order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paypalOrderId: data.orderID, orderId, }), }); const result = await response.json(); if (!response.ok) { throw new Error(result.message || 'Failed to capture payment'); } onSuccess(result); } catch (error) { onError(error); } }} onCancel={onCancel} onError={(error) => { console.error('PayPal error:', error); onError(error); }} /> </PayPalScriptProvider> ); }

Webhook Configuration

// services/paypal/WebhookService.ts import crypto from 'crypto'; export class PayPalWebhookService { async verifyWebhookSignature( headers: Record<string, string>, body: string ): Promise<boolean> { const webhookId = process.env.PAYPAL_WEBHOOK_ID!; // PayPal webhook verification requires API call const verifyRequest = { auth_algo: headers['paypal-auth-algo'], cert_url: headers['paypal-cert-url'], transmission_id: headers['paypal-transmission-id'], transmission_sig: headers['paypal-transmission-sig'], transmission_time: headers['paypal-transmission-time'], webhook_id: webhookId, webhook_event: JSON.parse(body), }; try { const response = await fetch( `${this.getBaseUrl()}/v1/notifications/verify-webhook-signature`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await this.getAccessToken()}`, }, body: JSON.stringify(verifyRequest), } ); const result = await response.json(); return result.verification_status === 'SUCCESS'; } catch (error) { console.error('Webhook verification failed:', error); return false; } } async handleWebhook(event: any): Promise<void> { const { event_type, resource } = event; // Idempotency check const existing = await this.webhookRepo.findByEventId(event.id); if (existing) return; await this.webhookRepo.create({ eventId: event.id, eventType: event_type, status: 'processing', }); switch (event_type) { case 'PAYMENT.CAPTURE.COMPLETED': await this.handleCaptureCompleted(resource); break; case 'PAYMENT.CAPTURE.DENIED': await this.handleCaptureDenied(resource); break; case 'PAYMENT.CAPTURE.REFUNDED': await this.handleRefund(resource); break; case 'CUSTOMER.DISPUTE.CREATED': await this.handleDisputeCreated(resource); break; case 'CUSTOMER.DISPUTE.RESOLVED': await this.handleDisputeResolved(resource); break; } await this.webhookRepo.markCompleted(event.id); } }

Common Integration Pitfalls

1. Amount Precision Issues

// ❌ BAD: Floating point errors const amount = 19.99 + 5.00; // Might be 24.990000000000002 // ✅ GOOD: Work in cents, convert at the end const amountCents = 1999 + 500; const paypalAmount = (amountCents / 100).toFixed(2); // "24.99"

2. Missing Item Total Validation

PayPal validates that items sum to the total:

// PayPal will reject if this doesn't match const itemTotal = items.reduce((sum, item) => sum + item.unitAmount * item.quantity, 0); const breakdown = { item_total: itemTotal.toFixed(2), shipping: shippingAmount.toFixed(2), tax_total: taxAmount.toFixed(2), }; // amount.value must equal item_total + shipping + tax_total

Conclusion

PayPal integration requires attention to:

  1. Amount precision - Always use fixed-point arithmetic
  2. Breakdown validation - Items must sum correctly
  3. Webhook reliability - Verify signatures, handle idempotently
  4. Dispute handling - Have a process ready
  5. Error mapping - PayPal errors need translation for users

PayPal complements card payments—it's not a replacement.

Related Articles

Security Engineering18 min read

API Security Hardening: A Practitioner's Guide

Secure your APIs with rate limiting, input validation, and CORS configuration. Production-tested checklist covering authentication, encryption, and error handling.

Backend Design17 min read

Queue-Based Architecture for Reliable Processing

Build reliable message queue systems with Redis, RabbitMQ, and AWS SQS. Covers dead letter queues, idempotency, and real-world processing patterns.

Payment Integrations24 min read

Braintree Payment Integration: Drop-in and Custom Approaches

Complete Braintree integration covering Drop-in UI, hosted fields, PayPal and Venmo, vault for recurring payments, and production deployment patterns for marketplace and subscription businesses.