PayPal Billing.
Set up PayPal subscription billing for partners, step by step.
This is the full setup guide for PayPal. For the billing overview, the plan tiers, and the other modes, see Billing Configuration.
What this mode does
PayPal runs your partner subscriptions through the PayPal REST Subscriptions API. A partner approves the subscription on PayPal, returns to the app, and the app captures it and sets the plan. From then on, a webhook keeps the plan in step with PayPal.
No hosted portal. PayPal has no equivalent of Stripe's hosted billing portal. Partners cancel and change plans on in-app screens on the My Plan page. A plan change is a cancel followed by a new subscription, because PayPal cannot revise a plan in place. See What partners see on My Plan.
When to use it
Use PayPal when your partners prefer to pay with PayPal. PayPal handles the approval and the payments, the app captures the result and keeps each partner's plan in step, and partners manage their subscription from in-app screens.
Before you start
- A PayPal business account.
- Your site reachable over HTTPS at a public domain, so PayPal can deliver webhooks.
- Plan tiers set the way you want in
config/plans.php. See Configuring plan values from the environment.
⚠️ Keep sandbox and live separate. PayPal keeps sandbox and live apart across the app credentials, the billing plans, and the webhook.
PAYPAL_MODEselects which set the app talks to, so everything you create has to live in the same environment. Build and confirm in sandbox first, then repeat in live. Never mix a sandbox value with a live value.
Step 1: Create a REST app and copy the credentials
- Sign in to the PayPal Developer Dashboard with your PayPal business account.
- Set the Sandbox / Live toggle to the environment you are configuring. It must match your planned
PAYPAL_MODE. - Open Apps & Credentials, find the REST API apps section, and click Create App (or open an app you already made).
- Copy the Client ID, and next to Secret, click to reveal and copy the value.
Sandbox and live have separate apps and separate credentials. The pair you copy has to come from the environment that matches PAYPAL_MODE. See PayPal's REST API credentials for more.
Step 2: Create one plan per paid tier and copy the Plan IDs
You need a PayPal billing plan for each paid tier and interval you sell, for example Silver monthly, Silver yearly, Gold monthly. A plan ID looks like P-1AB23456CD789012EF34GHIJ.
Without code (PayPal business account). Sign in to your PayPal business account and go to Pay & Get Paid → Accept Payments → Subscriptions. Open Subscription plans and click Create Plan. Set the price and the billing cycle (monthly or yearly), then turn the plan on. To read the Plan ID back, open your plans list at paypal.com/billing/plans (use sandbox.paypal.com/billing/plans while testing). Each plan shows its P-… ID.
With the API. Get an access token, create one catalog product, then create one plan per tier and interval. Use the live host api-m.paypal.com when you go live.
# 1. Get an access token
curl -s https://api-m.sandbox.paypal.com/v1/oauth2/token \
-u "PAYPAL_CLIENT_ID:PAYPAL_SECRET" \
-d "grant_type=client_credentials"
# 2. Create a product once. The response holds an id like PROD-XXXXXXXX
curl -s -X POST https://api-m.sandbox.paypal.com/v1/catalogs/products \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Reward Loyalty","type":"SERVICE","category":"SOFTWARE"}'
# 3. Create a plan. The response holds the Plan ID, e.g. P-XXXXXXXX
curl -s -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-XXXXXXXX",
"name": "Gold (monthly)",
"billing_cycles": [ /* pricing + frequency, see the link below */ ]
}'
The id in the create-plan response (the P-… value) is what you map in .env. For the full billing_cycles shape, see PayPal's Create plan reference.
Step 3: Add the credentials and plan IDs to .env
# Billing provider
BILLING_PROVIDER=paypal
# PayPal REST app credentials (Apps & Credentials)
PAYPAL_CLIENT_ID=...
PAYPAL_SECRET=...
# sandbox (default) or live: must match where you created the app, plans, and webhook
PAYPAL_MODE=sandbox
# Webhook id (you get this in Step 4)
PAYPAL_WEBHOOK_ID=...
# Plan IDs: one monthly and one yearly per paid tier.
# Tier 1 is the free tier, so it needs no plan ID.
PAYPAL_PLAN_TIER2_MONTHLY=P-...
PAYPAL_PLAN_TIER2_YEARLY=P-...
PAYPAL_PLAN_TIER3_MONTHLY=P-...
PAYPAL_PLAN_TIER3_YEARLY=P-...
PAYPAL_PLAN_TIER4_MONTHLY=P-...
PAYPAL_PLAN_TIER4_YEARLY=P-...
⚠️ Three values are required:
PAYPAL_CLIENT_ID,PAYPAL_SECRET, andPAYPAL_WEBHOOK_ID. The provider stays unconfigured until all three are set.PAYPAL_MODEis optional and defaults tosandbox; setPAYPAL_MODE=livewhen you move to live. Without the webhook id, the app cannot verify signatures, so the webhook endpoint rejects every request with403 Forbidden.
The credentials live in config/services.php under the paypal key. The plan IDs load through config/default.php, so they keep working with php artisan config:cache.
Step 4: Create the webhook and copy the Webhook ID
Your webhook endpoint is:
https://your-domain.com/api/paypal/webhook
- In the PayPal Developer Dashboard, set the Sandbox / Live toggle to match
PAYPAL_MODE. - Open Apps & Credentials and select your app.
- Scroll to the Webhooks section and click Add Webhook.
- In Webhook URL, enter
https://your-domain.com/api/paypal/webhook. - Under Event types, select these (or pick All events):
BILLING.SUBSCRIPTION.ACTIVATEDBILLING.SUBSCRIPTION.UPDATEDBILLING.SUBSCRIPTION.CANCELLEDBILLING.SUBSCRIPTION.SUSPENDEDBILLING.SUBSCRIPTION.EXPIREDPAYMENT.SALE.COMPLETED(renewal payments)
- Click Save.
PayPal now lists the webhook with an ID that looks like WH-1AB23456CD789012E-3FG45678HI901234J. Copy that Webhook ID into .env as PAYPAL_WEBHOOK_ID. Sandbox and live each have their own app and their own webhook, so create the webhook under the same environment as your credentials.
Step 5: Confirm
Open the Health Center. The Billing Provider check reads PayPal in green once the credentials and the webhook id are set, and its detail line shows whether you are in sandbox or live. To confirm delivery, open the webhook in the PayPal dashboard and review recent deliveries: a healthy delivery returns 200. A 403 means PAYPAL_WEBHOOK_ID is missing or wrong (see Troubleshooting).
Test before you go live
- With sandbox credentials, sandbox plan IDs, and a sandbox webhook in place, register a test partner and subscribe with a sandbox buyer account.
- Confirm the partner's My Plan page shows the new tier and an Active badge.
- When the flow works end to end, repeat Steps 1 to 4 in live: create a live app, create live plans, and create a live webhook. Set
PAYPAL_MODE=liveand put the live values in.env.
What partners see on My Plan
PayPal mode shows the same status badge, plan comparison, and monthly/yearly toggle as the other paid mode. Self-service differs, because there is no hosted portal:
- Without an active subscription, partners see Upgrade, which starts the PayPal approval redirect.
- With an active subscription, partners see Change Plan, which opens a confirmation that explains the cancel-and-resubscribe step, plus a Cancel Subscription control with a two-step confirm.
- The "Manage Billing" portal button stays hidden.
Admin-created and legacy partners (those with a created_by value) see the plan cards but no self-service buttons. They contact their administrator for changes.
How the two in-app controls work:
- Cancel. A two-step confirm cancels the PayPal subscription. Access restricts once PayPal reports it cancelled. The partner can subscribe again at any time.
- Change plan. PayPal cannot revise a plan in place, so changing cancels the current subscription and starts a new one. The partner confirms, the app cancels the current subscription first so they are never billed twice, then sends them to PayPal to approve the new plan. If they leave without approving, they have no active subscription and can subscribe again.
Subscription states
The provider maps PayPal status onto the shared entitlement vocabulary (see the state reference):
| PayPal status | Entitlement state | Access |
|---|---|---|
| (no subscription) | legacy |
Full plan access (pre-billing) |
ACTIVE |
active |
Full plan access |
APPROVAL_PENDING, APPROVED |
incomplete |
Restricted until activation finishes |
SUSPENDED |
suspended |
Restricted |
CANCELLED, EXPIRED |
cancelled |
Restricted; plan label kept |
| (unknown) | suspended |
Restricted, as a fail-safe |
legacy never restricts access. The mapping is fail-safe: an unrecognized status restricts access rather than granting it.
Webhook synchronization safety
PayPal cannot use Laravel Cashier, so the handler is hand-rolled and hardened for correctness:
- Verifies the signature first. It checks the transmission id, signature, certificate URL, and timestamp with PayPal before it trusts the event.
- Reads the live subscription. Rather than trusting the status in the payload, the handler fetches the live subscription from PayPal and syncs from that. Re-running the same event reaches the same result, and a late or out-of-order event still resolves to the current truth.
- Processes each event once. It keys on the PayPal event id to skip duplicates.
- Active-subscription guard. It moves the plan only for an
ACTIVEsubscription. Cancelled, suspended, expired, and approval-pending states save the status so access restricts, but they never grant a paid tier and never drop the plan label. - Strict plan-ID mapping. It reverse-maps the live plan id against
config/default.phpto find the tier. It ignores an unknown plan id. - Ties back to the partner. The subscription's
custom_idcarries the partner id set at checkout, so the handler finds the partner without trusting any session.
Troubleshooting
| Symptom | Likely cause | Resolution |
|---|---|---|
Webhook deliveries return 403 |
PAYPAL_WEBHOOK_ID is missing, or it does not match the webhook sending the events |
Set PAYPAL_WEBHOOK_ID to the id of the exact webhook in the dashboard. The Health Center shows PayPal (webhook insecure) when it is missing. |
Webhook returns 200 but the plan does not change |
The event was for a non-ACTIVE state, the plan id is not mapped, or custom_id did not match a partner |
Confirm the PAYPAL_PLAN_* mapping matches the live plan id, and that the subscription started inside the app, so custom_id holds the partner id. |
Partner stuck on APPROVAL_PENDING |
The buyer approved but PayPal has not activated yet, or the buyer left the approval page | PayPal activates within moments, and the ACTIVATED webhook syncs it. If the buyer left, they subscribe again. No state is corrupted. |
| Partner asks why "Change Plan" cancels their subscription | Expected. PayPal cannot revise a plan in place | A plan change is a cancel then a new subscription. The confirmation dialog says so, and the app cancels the old subscription first, so there is no double charge. |
| Tokens or webhooks rejected after switching environments | PAYPAL_MODE does not match where you created the credentials, plans, and webhook |
Keep PAYPAL_MODE, the app credentials, the P-… plan ids, and the webhook in one environment. The Health Center detail line shows the active mode. |
| You need to manage a partner's subscription | There is no admin-side PayPal portal | The partner cancels or changes from My Plan, or you act on the subscription in the PayPal dashboard. The next webhook, or the partner reopening My Plan, re-syncs local state. |
Quick reference
# === PayPal (.env) ===
BILLING_PROVIDER=paypal
# Credentials: Apps & Credentials (match the Sandbox/Live toggle)
PAYPAL_CLIENT_ID=...
PAYPAL_SECRET=...
PAYPAL_MODE=sandbox # or "live"
PAYPAL_WEBHOOK_ID=WH-... # the app's Webhooks section, after Add Webhook
# Plan IDs: Subscriptions → Subscription plans, or the billing/plans API (P-...)
PAYPAL_PLAN_TIER2_MONTHLY=P-...
PAYPAL_PLAN_TIER2_YEARLY=P-...
PAYPAL_PLAN_TIER3_MONTHLY=P-...
PAYPAL_PLAN_TIER3_YEARLY=P-...
PAYPAL_PLAN_TIER4_MONTHLY=P-...
PAYPAL_PLAN_TIER4_YEARLY=P-...