Menu

PayPal Integration

PayPal has a huge installed user base in North America, Europe and Latin America. Plenty of people would rather pay with their PayPal balance or a linked account than type a card number into a site they have never bought from before. Adding PayPal alongside Stripe lets you capture those orders.

NEXTY.DEV's PayPal integration supports two selling models:

  • One-time credit packs: the pricing page renders a PayPal button and the payment completes in a popup, without leaving the page.
  • Recurring subscriptions: clicking the button sends the user to PayPal's approval page, then back to your site.

This chapter covers the setup on PayPal's side and how to wire the credentials into the boilerplate.

Good to know:

  • PayPal is an optional integration. If Stripe is your only payment channel, skip this chapter.
  • PayPal and Stripe can run side by side — the provider field on each pricing plan decides which channel it goes through.

Registration and Prerequisites

  1. Register a PayPal Business account. A personal account cannot create subscription products or take commercial payments.

  2. Sign in to the PayPal Developer Dashboard with that account. The developer dashboard and the merchant dashboard are two separate interfaces — almost everything in this chapter happens in the developer dashboard.

Attention:

There is a Sandbox / Live toggle in the top left of the developer dashboard. Stay in Sandbox for the whole development phase, and only redo the same steps in Live once the payment flow works end to end. Credentials, webhooks and subscription plans are completely separate between Sandbox and Live — nothing carries over.

Creating an App and Getting Credentials

  1. Go to Apps & Credentials, make sure you are in Sandbox mode, click Create App and choose the Merchant type.
paypal-create-app
  1. Open the app detail page and you will find the Client ID and Secret key (click Show to reveal the secret).
paypal-app-credentials
  1. Put them into your environment variables:
# Master PayPal switch, read by the global PayPalProvider
NEXT_PUBLIC_ENABLE_PAYPAL=true
# Use sandbox during development, switch to live when you go to production
NEXT_PUBLIC_PAYPAL_ENVIRONMENT=sandbox
NEXT_PUBLIC_PAYPAL_CLIENT_ID=your_client_id
PAYPAL_CLIENT_SECRET=your_secret_key

Good to know:

NEXT_PUBLIC_PAYPAL_ENVIRONMENT decides which API host the boilerplate talks to (api-m.sandbox.paypal.com or api-m.paypal.com), and it also decides whether the pricing config reads the test or the live plan ID. This value and your Client ID must come from the same environment, otherwise every call fails authentication.

Creating a Sandbox Test Account

In Sandbox you cannot pay with your own real PayPal account — you need the test accounts PayPal generates for you.

Go to Testing Tools - Sandbox Accounts. PayPal has already created a Business account (the payee) and a Personal account (the payer). Click ⋮ - View/Edit account on the Personal account to see its login email, and reset the password to something you will remember.

paypal-sandbox-accounts

Use that Personal account to sign in on the PayPal payment page when you test.

Creating a Webhook

Payment results, refunds and subscription renewals all reach your database through webhooks — this step is not optional.

  1. Back in Apps & Credentials, open the app you just created, scroll down to the Webhooks section and click Add Webhook.
paypal-webhook-create
  1. Fill in the webhook URL:
  • For local development, use your tunnel address + the API path (<your_forwarded_address>/api/paypal/webhook)
  • For production, use your production address + the API path (<your_server_address>/api/paypal/webhook)

Getting a tunnel address works exactly as described in the Stripe chapter: in Cursor or VSCode set up a Forward Port, enter your local server port, switch it to Public, and copy the Forwarded Address.

forward-port

Attention:

PayPal does not accept http://localhost as a webhook URL — it has to be a publicly reachable https address. If you would rather not set up tunneling right now, you can skip local webhook testing for the moment; see "Testing Locally" below.

  1. Select the event types. The boilerplate handles the following 15 events, and we recommend subscribing to all of them:
One-time payments (credit packs)
- PAYMENT.CAPTURE.COMPLETED
- PAYMENT.CAPTURE.PENDING
- PAYMENT.CAPTURE.DENIED
- PAYMENT.CAPTURE.DECLINED
- PAYMENT.CAPTURE.REFUNDED
- PAYMENT.CAPTURE.REVERSED
 
Subscription charges
- PAYMENT.SALE.COMPLETED
- PAYMENT.SALE.REFUNDED
- PAYMENT.SALE.REVERSED
 
Subscription lifecycle
- BILLING.SUBSCRIPTION.ACTIVATED
- BILLING.SUBSCRIPTION.UPDATED
- BILLING.SUBSCRIPTION.SUSPENDED
- BILLING.SUBSCRIPTION.PAYMENT.FAILED
- BILLING.SUBSCRIPTION.CANCELLED
- BILLING.SUBSCRIPTION.EXPIRED

Event types you did not subscribe to (CHECKOUT.ORDER.APPROVED, for example) are silently acked rather than treated as errors, so subscribing to extra events does no harm.

  1. After saving, the webhook list shows the webhook's ID. Copy it into your environment variables:
PAYPAL_WEBHOOK_ID=your_webhook_id

Attention:

PAYPAL_WEBHOOK_ID is required for signature verification. PayPal verifies differently from Stripe: instead of computing an HMAC locally with a secret, the boilerplate sends the signature headers together with this webhook ID back to PayPal's verify-webhook-signature endpoint. Get this value wrong — or leave it empty — and every webhook is rejected.

Pricing Plans Live in Code

Before configuring either selling model, one thing to be clear about: pricing plans in NEXTY.DEV v4 are config as code. They all live in config/pricing.ts — no database table, no admin CRUD. Each plan declares who collects the money through its provider field, so PayPal plans use provider: 'paypal'.

One-time credit packs and recurring subscriptions need completely different preparation on PayPal's side:

One-time credit packRecurring subscription
On PayPal's sideNothing to createProduct and Plan required
Pricing configJust price + currencyAlso needs paypalPlanId
FrontendPayPal button embedded in the card, popup checkoutRedirect to PayPal approval page, then back

Attention:

Never delete a plan you have already sold, and never change its id. To retire it, set active: false. Historical orders and renewal invoices resolve plans through this slug, so deleting one makes webhook handling throw — and PayPal will retry for days.

Configuring One-Time Payments (Credit Packs)

One-time payments require nothing to be created in the PayPal dashboard. When a user clicks the button, the backend creates an order on the fly through the Orders API using the amount in config/pricing.ts, so this step is code only:

{
  id: 'pack-mini-paypal',          // stable slug, referenced by orders and credit history forever
  kind: 'credit_pack',             // a credit pack, not a subscription
  credits: 300,                    // credits granted once the payment succeeds
  provider: 'paypal',
  price: 5.9,                      // the amount actually charged
  currency: 'USD',
  active: true,
  copy: { en: { name: 'Mini Pack', description: '...', features: ['...'] } },
}

Note that there is no paypalPlanId here — credit packs do not need one, and adding it has no effect.

Once configured, the pricing card automatically swaps its CTA for the official PayPal buttons (the conditions are provider: 'paypal' plus kind: 'credit_pack', with NEXT_PUBLIC_PAYPAL_CLIENT_ID set). The full flow is:

User clicks the PayPal button
  → POST /api/paypal/create-order  backend creates the order from the configured amount
  → user pays in the PayPal popup (never leaves the page)
  → POST /api/paypal/capture-order  backend captures and grants credits immediately
  → redirect to /payment/success

Good to know:

  • The amount always comes from the server-side config. Prices sent by the client are never trusted, so there is nothing for a user to tamper with.
  • currency is used to initialize the PayPal JS SDK. It must be a currency PayPal supports and one your merchant account can actually receive.
  • The boilerplate disables direct card payments by default (disableFunding: "card"), so checkout runs through PayPal accounts only. Change lib/paypal/script-options.ts if you want to allow them.
  • Credit packs can only be bought from a personal workspace. Ordering from a team workspace is blocked with a prompt to switch back.
  • If the user pays by eCheck or a similar method, the capture comes back PENDING — the money has not settled yet. The boilerplate records a pending order and grants no credits, waiting for the PAYMENT.CAPTURE.COMPLETED event to top them up.

Configuring Recurring Subscriptions: Products and Plans

Subscriptions are the opposite of credit packs: the plan must exist on PayPal's side before you can sell it.

A PayPal subscription is built from two objects: a Product and a Plan (which carries the price and the billing cycle) — roughly the same relationship as Stripe's Product and Price.

Option 1: Create Them in the Merchant Dashboard

  1. For Sandbox, sign in to sandbox.paypal.com with the Sandbox Business test account from the previous step. For Live, sign in to paypal.com with your real merchant account.

  2. Go to Pay & Get Paid - Subscriptions and create a product.

paypal-subscription-product
paypal-subscription-product
paypal-subscription-product
  1. Create a plan for that product with the amount, currency and billing cycle.
paypal-subscription-plan
  1. Once created, you can see the plan ID, which looks like P-5ML4271244454362WXNWU5NQ.
paypal-plan-id

Option 2: Create Them Through the API

In some countries and regions the PayPal dashboard has no Subscriptions menu at all, and the API is your only option.

First exchange the Client ID and secret for an access token:

curl -v -X POST "https://api-m.sandbox.paypal.com/v1/oauth2/token" \
  -u "CLIENT_ID:CLIENT_SECRET" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials"

Create the product:

curl -v -X POST "https://api-m.sandbox.paypal.com/v1/catalogs/products" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Pro Plan",
    "description": "Nexty Pro subscription",
    "type": "SERVICE",
    "category": "SOFTWARE"
  }'

Use the returned id to create the plan:

curl -v -X POST "https://api-m.sandbox.paypal.com/v1/billing/plans" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "product_id": "PROD-XXXXXXXXXXXX",
    "name": "Pro Monthly",
    "status": "ACTIVE",
    "billing_cycles": [
      {
        "frequency": { "interval_unit": "MONTH", "interval_count": 1 },
        "tenure_type": "REGULAR",
        "sequence": 1,
        "total_cycles": 0,
        "pricing_scheme": {
          "fixed_price": { "value": "29.90", "currency_code": "USD" }
        }
      }
    ],
    "payment_preferences": {
      "auto_bill_outstanding": true,
      "setup_fee_failure_action": "CONTINUE",
      "payment_failure_threshold": 3
    }
  }'

The id in the response (the one starting with P-) is the plan ID you need.

Good to know:

  • total_cycles: 0 means bill forever, which is what you want for a subscription.
  • A plan's price cannot be freely changed after creation — repricing means creating a new plan. Same as a Stripe Price, so think your pricing through before you create it.
  • The plan status must be ACTIVE. A plan left in CREATED cannot be used for checkout.

Wiring the Plan ID into the Pricing Config

With the P- plan ID in hand, go back to config/pricing.ts and fill in paypalPlanId on the subscription plan:

{
  id: 'pro-monthly-paypal',        // stable slug, referenced by orders and credit history forever
  kind: 'subscription',
  interval: 'month',
  monthlyCredits: 2000,            // the bucket resets to this on every successful charge
  provider: 'paypal',
  paypalPlanId: {
    test: 'P-REPLACE_paypal_sandbox',  // Sandbox plan ID
    live: 'P-REPLACE_paypal_live',     // Live plan ID
  },
  price: 29.9,                     // display price, must match the amount on the PayPal plan
  currency: 'USD',
  active: true,
  copy: { en: { name: 'Pro', description: '...', features: ['...'] } },
}

Attention:

  • Unlike credit packs, a subscription's price is for display only — the amount actually charged comes from the PayPal plan. If the two disagree, users see one number and get charged another, so double-check them.
  • NEXT_PUBLIC_PAYPAL_ENVIRONMENT decides whether test or live is read. Fill in both.

For more on pricing configuration, see the payment system documentation.

Testing Locally

Test the two flows separately, paying with the Sandbox Personal test account in both cases.

One-Time Payment

  1. Open the pricing page and confirm the credit pack card's CTA has turned into the official PayPal buttons. If the buttons never appear, check that NEXT_PUBLIC_PAYPAL_CLIENT_ID is set and that the plan's provider and kind are correct.
  2. Click the button and pay with the test account in the popup. You stay on the pricing page — the frontend calls the capture endpoint and then navigates to /payment/success.
  3. Check the order row and the user's credit balance in the database, and confirm the amount and credits match the config.

Recurring Subscription

  1. Click the subscribe button and confirm you land on PayPal's approval page (a URL like sandbox.paypal.com/webapps/billing/subscriptions?ba_token=...). If nothing happens, paypalPlanId is usually wrong or the plan is not ACTIVE.
  2. Approve, come back to the success page, and check the subscription's status and the credits for the current period.
  3. You do not have to wait for a real billing cycle to test renewals — use the script below.

Webhooks

The boilerplate skips signature verification when NODE_ENV=development (PayPal cannot deliver a verifiable real event to localhost), so locally you can POST events straight to /api/paypal/webhook. There is a script for simulating a subscription renewal:

# First edit subscriptionId / userId / planId and the other fields in the CONFIG block
node scripts/test-paypal-renewal.mjs
 
# You can also override them from the command line
node scripts/test-paypal-renewal.mjs --saleId=SALE-123 --amount=9.99

Attention:

PayPal webhooks are idempotent by event ID. Fire the same saleId twice and the server treats it as already processed and skips it. Use a fresh ID for each test run.

Switching to Live Mode

Once Sandbox works end to end, the whole setup has to be redone in Live. Treat the list below as a checklist:

  1. Switch the developer dashboard to Live, create a Live app, and grab the new Client ID and secret.
  2. Update the environment variables: set NEXT_PUBLIC_PAYPAL_ENVIRONMENT=live and swap in the Live NEXT_PUBLIC_PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET.
  3. Create a new webhook under the Live app pointing at your production domain, subscribe to the same events, and put the new webhook ID into PAYPAL_WEBHOOK_ID.
  4. Recreate the subscription product and plan in your real merchant dashboard and put the P- Live plan ID into paypalPlanId.live in config/pricing.ts. One-time credit packs skip this step — they have no object on PayPal's side and start working as soon as the credentials are swapped.
  5. Confirm your merchant account has completed email verification and is cleared to receive payments, otherwise orders get stuck in Pending.

Attention:

The Live webhook URL must match the address your site is actually served from (with and without www are two different addresses), or the events never arrive.

A Few Differences from Stripe

Knowing these upfront saves you some pain:

  • There is no hosted billing portal. Stripe has the Billing Portal; PayPal has no equivalent, so "Manage subscription" in the boilerplate sends users to their own PayPal automatic payments page at https://www.paypal.com/myaccount/autopay/. Cancellation is done by the boilerplate through the API.
  • A billing subject can only hold one active subscription at a time. Switching plans means cancelling first and then buying — otherwise checkout is rejected.
  • Payments can sit in a Pending state. With eCheck and similar methods PayPal returns PENDING first, before the money has settled. At that point the boilerplate records a pending order and grants no credits, waiting for PAYMENT.CAPTURE.COMPLETED to actually release them. If that never arrives, the /api/cron/credits job reconciles it as a safety net — remember to set CRON_SECRET and wire up the scheduled trigger.
  • Credit packs cannot be bought from a team workspace, only from a personal one. Same rule as Stripe.

Wrapping Up

That completes the PayPal integration. Before switching to Live, we recommend running all three paths in Sandbox at least once: "one-time payment → credits granted", "subscribe → renew → cancel", and "refund → credits clawed back".