PayPal

PayPal takes every payment in Offer SDK. Each app connects its own PayPal REST app, the API prices each checkout itself, and PayPal's webhooks keep accounts in step with billing.

Connect a PayPal REST app

Credentials belong to an Offer app, not to the deployment. Two apps on one API can bill through two different PayPal accounts, and buyers pay the app owner directly.

  1. Create a REST app in PayPal

    At developer.paypal.com, open Apps & Credentials, pick Sandbox or Live, and create an app. Copy its client ID and secret.

  2. Paste them into the dashboard

    In the dashboard, open the app's Settings and find Payments. Choose Sandbox or Live to match the credentials, paste both values and press Connect PayPal.

    The API checks the pair by requesting an OAuth token from PayPal. If PayPal rejects it, you get a 400 and nothing is saved. If it works, the secret is encrypted and stored, and the dashboard never shows it again.

  3. Set a checkout page (optional)

    The same section has a Checkout page field: the URL of your page that uses the checkout SDK. Offer links and over-limit upgrade links send buyers there with ?offer=…. Without it, over-limit responses carry a PayPal approval link instead.

The same connection is available over the API with the app's secret key or the admin key.

  • GET/apps/:appId/paypal
    Connection status: env, client ID, and whether the webhook is registered.
  • PUT/apps/:appId/paypal
    Body { client_id, client_secret, env }, where env is sandbox (the default) or live. Verifies, stores and registers the webhook.
  • DELETE/apps/:appId/paypal
    Disconnects. Existing PayPal subscriptions keep billing, but their webhooks can no longer be verified.
DetailBehavior
Base URLhttps://api-m.sandbox.paypal.com for sandbox, https://api-m.paypal.com for live. PAYPAL_API_URL overrides both for every app (the API's tests point it at a fake PayPal).
Secret storageAES-256-GCM in Postgres. The key comes from SECRETS_KEY, then BETTER_AUTH_SECRET, then ADMIN_API_KEY. If that value changes, stored secrets can't be read and each app has to reconnect.
Switching accountsConnecting a different client ID or env clears the app's cached PayPal product and billing plans, since they belong to the old account. Reconnecting the same pair keeps them.
Public client IDGET /apps/:appId/offers/:offerId/public returns the client ID and env so the browser can load PayPal's buttons. The secret never leaves the API.

How a checkout works

The browser never sends a price. It sends the offer, the plan, the interval and any order bumps, and the API prices that selection from the offer stored in Postgres. The quote is saved on the checkout row, and the PayPal object is created from it.

Start a checkout (publishable key)
curl -X POST https://your-api.example.com/apps/$APP_ID/offers/$OFFER_ID/checkout \
  -H "Authorization: Bearer $OFFER_PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "plan": "pro", "interval": "month", "bumps": ["onboarding"], "account": "user_42" }'

The response is the checkout, with paypal.kind (subscription or order), paypal.id and approve_url. The React SDK's Offer.Checkout hands paypal.id to PayPal's buttons. Redirect flows, such as emailed links, send the buyer to approve_url with your return_url and cancel_url.

After the buyer approves, the SDK calls POST /apps/:appId/checkouts/:checkoutId/complete. The API asks PayPal whether it was paid, captures one-time orders, and applies the purchase to the account. It returns status: "created" while PayPal is still activating, and the SDK polls until it's completed. The webhook does the same thing when it arrives, and a checkout is only granted once.

Plans without an offer. POST /apps/:appId/plans/:planId/checkout sells a plan at its list price. It's a shortcut for the default offer, which has every priced plan at list price.

Subscriptions and orders

IntervalPayPal objectWhat gets created
month, yearSubscriptionA billing plan under one PayPal catalog product per app (created on first use). Intro pricing becomes a TRIAL cycle at the sale price for the offer's number of cycles, then a REGULAR cycle at list price. The SDK loads PayPal's script with intent=subscription.
onceOrderAn order with intent: "CAPTURE", one line item for the plan and one per bump. The API captures it when the buyer has approved it.

Billing plans are cached by price shape (interval, currency, price, intro cycles and regular price), so every offer and checkout with the same shape reuses one PayPal plan. Publishing an offer creates its billing plans ahead of time, so the first buyer doesn't wait. Publishing fails if PayPal isn't connected.

Order bumps

On a subscription, the one-time bumps a buyer picks are added up and sent as the subscription's setup fee, which PayPal charges at activation. On a one-time order they're extra line items. Either way the bump prices come from the stored offer.

Plan changes and save-offer discounts

POST /apps/:appId/namespaces/:namespaceId/subscription/change moves a running subscription to another plan or interval. If the account's offer includes the new plan, the account keeps the offer's price, extras and any discounted cycles left. Otherwise it moves to list price and leaves the offer.

PayPal needs the buyer to agree to a new price, so the API creates (or reuses) a billing plan for it, calls PayPal's subscription revise endpoint, and returns PayPal's approve_url. The change is stored as subscription.pending_change and applies when PayPal reports the new billing plan, through the webhook or a sync. Upgrades apply once approved. Downgrades wait for the renewal date, so nothing already paid for is lost.

A discount from a cancel flow uses the same path: the price the account would pay next, less the percent, for a set number of renewals. The customer approves it on PayPal like any other price change.

Cancel, pause and resume

  • POST/apps/:appId/namespaces/:namespaceId/subscription/cancel
    Cancels in PayPal, re-reads the subscription, and moves the account to the free plan.
  • POST/apps/:appId/namespaces/:namespaceId/subscription/pause
    Body { months, reason? }, 1 to 12 months. Suspends billing in PayPal and moves the account to the free plan until pause.resume_at.
  • POST/apps/:appId/namespaces/:namespaceId/subscription/resume
    Ends a pause now: reactivates billing in PayPal and restores the paused plan.
  • POST/apps/:appId/namespaces/:namespaceId/subscription/sync
    Re-reads the subscription from PayPal: status, renewal date, discounted cycles left and approved plan changes.

A pause uses PayPal's suspend call, and a resume uses its activate call. Due pauses resume on their own. A pause accepted in a cancel flow runs as a Render Workflow task when that's configured; see Render.

PayPal webhooks

PayPal posts each app's notifications to its own path on the API:

POST https://<your-api-host>/paypal/webhooks/<appId>

You don't add this URL in PayPal yourself. When you connect PayPal, the API registers the webhook with PayPal for the events below and stores the webhook ID it gets back. The Payments section then says webhooks are registered.

PayPal only accepts public HTTPS URLs, so the API needs to know its own address. It uses PUBLIC_API_URL when that's set, and otherwise the onrender.com URL Render gives every web service (RENDER_EXTERNAL_URL). A Blueprint deploy needs no setup. Set PUBLIC_API_URL to register a custom domain instead, or when the API runs outside Render.

Don't register the URL by hand in PayPal. Every delivery is verified against the webhook ID the API stored, so a hand-made webhook fails with Invalid signature. It also blocks the API from registering its own, because PayPal won't take the same URL twice. If an app connected before the API had a public address, press Replace in Payments and save the same credentials to register the webhook.

The API subscribes to these eight events:

PAYPAL_WEBHOOK_EVENTS in api/src/lib/paypal.ts
BILLING.SUBSCRIPTION.ACTIVATED
BILLING.SUBSCRIPTION.UPDATED
BILLING.SUBSCRIPTION.CANCELLED
BILLING.SUBSCRIPTION.SUSPENDED
BILLING.SUBSCRIPTION.EXPIRED
BILLING.SUBSCRIPTION.PAYMENT.FAILED
PAYMENT.SALE.COMPLETED
PAYMENT.CAPTURE.COMPLETED
EventWhat the API does
BILLING.SUBSCRIPTION.*Reads the subscription in the event. If a checkout is waiting on it and it's active, the checkout completes. Otherwise the account's subscription is updated: status (ACTIVE to active, SUSPENDED to suspended, CANCELLED or EXPIRED to cancelled), renewal date, discounted cycles left, and any pending plan change whose billing plan PayPal now shows. A cancellation moves the account to the free plan and sends subscription.cancelled.
PAYMENT.SALE.COMPLETEDA subscription payment. The sale doesn't carry billing details, so the API fetches the subscription and handles it the same way. A new payment after the first sends subscription.renewed.
PAYMENT.CAPTURE.COMPLETEDCompletes the one-time order checkout the capture belongs to.

Each delivery is checked with PayPal's verify-webhook-signature API, then recorded by event ID so a repeat is answered 200 with duplicate: true. If handling fails, the API forgets the event and answers 500, so PayPal's retry is processed again.

Without registered webhooks, checkouts still complete when the buyer comes back to your page and the SDK calls /complete. Renewals and cancellations made in PayPal won't reach the account until you call /subscription/sync.

Test in sandbox

  1. Connect sandbox credentials

    Create a sandbox REST app, then connect it in Settings → Payments with Sandbox selected. The Payments section shows a Sandbox badge.

  2. Publish an offer

    Build an offer and publish it. This creates the PayPal product and billing plans in your sandbox account.

  3. Pay as a sandbox buyer

    Open your checkout page and pay with a sandbox personal account from your PayPal developer dashboard. No real money moves.

  4. Check the account

    The account moves to the plan and gets a subscription record. Your own webhook endpoints receive checkout.completed.

On your machine, the API isn't on a public HTTPS address, so PayPal webhooks aren't registered. Checkouts complete through /complete, and /subscription/sync picks up anything you change in PayPal. To exercise the webhooks, deploy to Render, where the API registers them at its onrender.com address.

With Bun installed, api/scripts/seed-demo.ts seeds a sample catalog, a half-off offer and your sandbox credentials for the dashboard's /demo/checkout page:

cd api
PAYPAL_CLIENT_ID=... PAYPAL_CLIENT_SECRET=... bun scripts/seed-demo.ts

For the checkout components, see Checkout. For the events your app receives, see Webhooks.