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
providerfield on each pricing plan decides which channel it goes through.
Registration and Prerequisites
-
Register a PayPal Business account. A personal account cannot create subscription products or take commercial payments.
-
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
- Go to Apps & Credentials, make sure you are in Sandbox mode, click Create App and choose the Merchant type.

- Open the app detail page and you will find the Client ID and Secret key (click Show to reveal the secret).

- 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_keyGood to know:
NEXT_PUBLIC_PAYPAL_ENVIRONMENTdecides which API host the boilerplate talks to (api-m.sandbox.paypal.comorapi-m.paypal.com), and it also decides whether the pricing config reads thetestor theliveplan 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.

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.
- Back in Apps & Credentials, open the app you just created, scroll down to the Webhooks section and click Add Webhook.

- 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.

Attention:
PayPal does not accept
http://localhostas 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.
- 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.EXPIREDEvent 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.
- After saving, the webhook list shows the webhook's ID. Copy it into your environment variables:
PAYPAL_WEBHOOK_ID=your_webhook_idAttention:
PAYPAL_WEBHOOK_IDis 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'sverify-webhook-signatureendpoint. 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 pack | Recurring subscription | |
|---|---|---|
| On PayPal's side | Nothing to create | Product and Plan required |
| Pricing config | Just price + currency | Also needs paypalPlanId |
| Frontend | PayPal button embedded in the card, popup checkout | Redirect to PayPal approval page, then back |
Attention:
Never delete a plan you have already sold, and never change its
id. To retire it, setactive: 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/successGood 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.
currencyis 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. Changelib/paypal/script-options.tsif 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.COMPLETEDevent 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
-
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.
-
Go to Pay & Get Paid - Subscriptions and create a product.



- Create a plan for that product with the amount, currency and billing cycle.

- Once created, you can see the plan ID, which looks like
P-5ML4271244454362WXNWU5NQ.

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: 0means 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 inCREATEDcannot 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
priceis 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_ENVIRONMENTdecides whethertestorliveis 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
- 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_IDis set and that the plan'sproviderandkindare correct. - 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. - Check the order row and the user's credit balance in the database, and confirm the amount and credits match the config.
Recurring Subscription
- 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,paypalPlanIdis usually wrong or the plan is notACTIVE. - Approve, come back to the success page, and check the subscription's status and the credits for the current period.
- 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.99Attention:
PayPal webhooks are idempotent by event ID. Fire the same
saleIdtwice 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:
- Switch the developer dashboard to Live, create a Live app, and grab the new Client ID and secret.
- Update the environment variables: set
NEXT_PUBLIC_PAYPAL_ENVIRONMENT=liveand swap in the LiveNEXT_PUBLIC_PAYPAL_CLIENT_IDandPAYPAL_CLIENT_SECRET. - 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. - Recreate the subscription product and plan in your real merchant dashboard and put the
P-Live plan ID intopaypalPlanId.liveinconfig/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. - 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
wwware 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.COMPLETEDto actually release them. If that never arrives, the/api/cron/creditsjob reconciles it as a safety net — remember to setCRON_SECRETand 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".