PayPal Setup
How to add PayPal as an alternative payment method alongside Stripe for customers who prefer wallet payments.
Why
A meaningful slice of customers — especially in Germany, Italy, and among older demographics — refuse to enter a card on a new site and will only pay via PayPal. A/B tests on consumer SaaS consistently show a 5-15% checkout-completion lift when PayPal is offered alongside card. PayPal also handles buyer protection disputes internally, which reduces your chargeback volume on the card side. The trade-off is higher fees (3.49% + $0.49 vs Stripe's 2.9% + $0.30 in the US) and a second integration to maintain, so the decision is geographic and demographic, not universal. For B2B SaaS the lift is near zero; for consumer products it is often the difference between hitting and missing revenue targets.
When
When your audience skews consumer or you sell in DACH/EU markets where PayPal has high adoption. Not worth it for pure B2B US-only SaaS.
How
1. Create a PayPal Business account (not Personal — Personal cannot accept card-funded PayPal payments)
2. Create a REST app in the PayPal Developer Dashboard to get client_id and client_secret for sandbox and live
3. Install @paypal/checkout-server-sdk, or use the newer Orders v2 API directly with fetch
4. On the pricing page, render a PayPal button alongside the Stripe button using the @paypal/react-paypal-js wrapper
5. On click, create an order server-side: POST /v2/checkout/orders with intent=CAPTURE and the purchase_units amount
6. After PayPal onApprove, capture the order server-side: POST /v2/checkout/orders/{id}/capture
7. Store the paypal_order_id and captured status; for subscriptions, use PayPal Subscriptions API with a billing plan + product
8. Set up a webhook for PAYMENT.CAPTURE.COMPLETED and BILLING.SUBSCRIPTION.CANCELLED to sync entitlements
9. Test in sandbox with the sandbox test accounts, then flip to live credentials behind a feature flagExample
// client: render PayPal button
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
<PayPalScriptProvider options={{ clientId: import.meta.env.VITE_PAYPAL_CLIENT_ID }}>
<PayPalButtons
createOrder={async () => {
const res = await fetch("/api/paypal/create-order", { method: "POST" });
const { id } = await res.json();
return id;
}}
onApprove={async (data) => {
await fetch("/api/paypal/capture-order", {
method: "POST",
body: JSON.stringify({ orderID: data.orderID }),
});
}}
/>
</PayPalScriptProvider>
// server: capture
const res = await fetch(`https://api-m.paypal.com/v2/checkout/orders/${orderID}/capture`, {
method: "POST",
headers: { Authorization: `Basic ${Buffer.from(clientId + ":" + secret).toString("base64")}` },
});
const capture = await res.json();
if (capture.status === "COMPLETED") await grantEntitlement(userId);Common Problems
- ⚠Using a Personal PayPal account — it cannot accept card-funded PayPal payments, so half your buyers get a 'this recipient cannot accept this payment type' error. Upgrade to Business.
- ⚠Capturing the order client-side instead of server-side — the client can be tampered with to change the amount. Always create and capture on the server; the client only relays the order id.
- ⚠Not handling currency conversion — PayPal converts at its own rate which is 2-4% worse than mid-market, so a $99 charge in EUR can show as $103 on your dashboard. Set the amount in the buyer's currency explicitly.
Best Practices
- ✓Offer PayPal only to geo-segments where adoption is high (DACH, Italy, Spain) — showing it to US B2B buyers adds clutter with no conversion lift.
- ✓Log the PayPal correlation_id on every API call — it is the single key PayPal support needs to find a transaction, and having it in your logs cuts support resolution time from days to minutes.
- ✓Keep Stripe as the primary and PayPal as a secondary button — PayPal's subscription management is weaker, so default new subscriptions to Stripe and reserve PayPal for one-time or consumer payments.