Last updated: October 29, 2026
Configure a subscription plan, initialize the SDK, and create a subscription session using a server-hosted integration. Verify subscription status server-side before you activate it in your system.
Before starting, ensure you have the following:
plan_id from an existing subscription plan. If you don't have one, create a product and create a subscription plan.subscriptionId to the SDK.A subscription integration moves through the following steps:
window.paypal.createInstance().findEligibleMethods() with paymentFlow: "RECURRING_PAYMENT".<paypal-button>.subscriptionId.onApprove callback fires. Your server verifies the subscription status is ACTIVE, then fulfills.PayPal subscriptions use clientId, which you fetch from your server endpoint.
Note: If your subscription flow includes Fastlane, use clientToken for authentication. Otherwise, use clientId.
Add a subscribe button to your HTML page and connect it to the JavaScript SDK v6 client in app.js.
Add the JavaScript SDK v6 core script to your HTML page. Use async to avoid blocking page render.
<script
async
src="https://www.sandbox.paypal.com/web-sdk/v6/core"
onload="onPayPalWebSdkLoaded()"
></script>Note: Switch to https://www.paypal.com/web-sdk/v6/core for production.
Add the <paypal-button> web component with type="subscribe". Keep it hidden until eligibility is confirmed.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Subscribe — JavaScript SDK v6</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
.buttons-container {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 400px;
}
</style>
</head>
<body>
<h1>Subscribe</h1>
<div class="buttons-container">
<!-- Hidden until eligibility is confirmed -->
<paypal-button id="paypal-button" type="subscribe" hidden></paypal-button>
</div>
<script src="app.js"></script>
<script
async
src="https://www.sandbox.paypal.com/web-sdk/v6/core"
onload="onPayPalWebSdkLoaded()"
></script>
</body>
</html>After the script loads, create an SDK instance using your client ID.
async function onPayPalWebSdkLoaded() {
try {
const sdkInstance = await window.paypal.createInstance({
clientId: (await (await fetch("/api/client-id")).json()).clientId, // from your server; never hardcode
components: ["paypal-subscriptions"],
pageType: "checkout",
});
await checkEligibilityAndSetup(sdkInstance);
} catch (error) {
console.error("SDK initialization error:", error);
}
}The createInstance() method accepts these parameters:
| Parameter | Required | Description |
|---|---|---|
clientId | Yes | Your PayPal REST app client ID. Fetch from server. |
components | Yes | ["paypal-subscriptions"]. Required for subscription flows. |
pageType | No | "checkout" is recommended for payment pages. |
Important: The paypal-billing-agreements component is deprecated in v6. Use paypal-subscriptions instead.
Always check eligibility before revealing the subscribe button. Pass paymentFlow: "RECURRING_PAYMENT" to scope the check to subscription flows. Define checkEligibilityAndSetup() as the function onPayPalWebSdkLoaded() calls in the previous step.
async function checkEligibilityAndSetup(sdkInstance) {
const paymentMethods = await sdkInstance.findEligibleMethods({
paymentFlow: "RECURRING_PAYMENT",
currencyCode: "USD",
});
if (paymentMethods.isEligible("paypal")) {
await setupSubscriptionButton(sdkInstance);
} else {
console.log("PayPal Subscriptions not eligible for this session");
}
}Create the subscription session with your callback handlers, then reveal the button and connect the click event.
The session options also accept optional onShippingAddressChange and onShippingOptionsChange callbacks. These callbacks fire when the buyer changes their shipping address or shipping option in the PayPal flow.
async function setupSubscriptionButton(sdkInstance) {
const session = sdkInstance.createPayPalSubscriptionPaymentSession({
// Called when the buyer approves the subscription.
// data contains subscriptionId and, when available, payerId.
async onApprove(data) {
// Always verify subscription status server-side before fulfilling
const res = await fetch(`/api/subscriptions/${data.subscriptionId}`);
if (!res.ok) {
console.error("Subscription verification failed:", res.status);
return;
}
const detail = await res.json();
if (detail.status === "ACTIVE") {
console.log("Subscription active:", data.subscriptionId);
// Fulfill in your system: redirect or show success
window.location.href = "/success";
}
},
// Called when the buyer cancels.
// data.subscriptionId is present only if a subscription was created.
onCancel(data) {
console.log("Subscription canceled:", data.subscriptionId ?? "");
},
// Called when an error occurs. The error object includes code and message.
onError(error) {
// Do not display error details to buyers.
console.error("Subscription error:", error.code, error.message);
},
});
// Reveal the button
const btn = document.getElementById("paypal-button");
btn.removeAttribute("hidden");
// On click: create subscription and start the session
btn.addEventListener("click", async () => {
try {
// Pass the Promise; do NOT await before passing to session.start()
const subscriptionPromise = createSubscription();
await session.start({ presentationMode: "auto" }, subscriptionPromise);
} catch (error) {
console.error("PayPal button click failure:", error);
}
});
}Important: Do not await the createSubscription() call before passing it to session.start(). The second argument must be a Promise that resolves to { subscriptionId: string }. If you await it first, the browser loses the transient activation the popup needs, which can cause the browser to block the popup.
Define createSubscription() to call your server’s create-subscription endpoint and return an object containing the subscription ID.
// Returns Promise<{ subscriptionId: string }>; do not await before passing to session.start()
async function createSubscription() {
const res = await fetch("/api/subscriptions/create", { method: "POST" });
if (!res.ok) {
throw new Error(`Create subscription failed: ${res.status}`);
}
const { subscriptionId } = await res.json();
if (!subscriptionId) {
throw new Error("Create subscription response is missing subscriptionId");
}
return { subscriptionId };
}Create subscriptions and verify their status using your access token. The following Node.js code is an example. Adapt the routes, session handling, and storage to your own stack. This code lives on your server.
Install the dependencies and set "type": "module" in your package.json so the import statements work:
npm install express node-fetch express-sessionSet these environment variables before you start the server: PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, and PAYPAL_PLAN_ID.
Exchange your client ID and secret for an access token before calling any Subscriptions API endpoint:
import express from "express";
import fetch from "node-fetch";
import session from "express-session";
const app = express();
app.use(express.json());
// Replace with your own session store and a secret from your environment.
// The sample uses the session to remember which subscription this buyer created.
app.use(
session({
secret: process.env.SESSION_SECRET || "replace-with-a-long-random-secret",
resave: false,
saveUninitialized: true,
})
);
const PAYPAL_BASE = process.env.PAYPAL_BASE || "https://api-m.sandbox.paypal.com";
async function getAccessToken() {
const creds = Buffer.from(
`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`
).toString("base64");
const res = await fetch(`${PAYPAL_BASE}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${creds}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
});
if (!res.ok) {
throw new Error(`Access token request failed: ${res.status}`);
}
const { access_token } = await res.json();
return access_token;
}Expose your client ID to the front end so you never hardcode it in app.js. The client ID is public, so don't return the client secret:
// GET /api/client-id
app.get("/api/client-id", (req, res) => {
res.json({ clientId: process.env.PAYPAL_CLIENT_ID });
});Create the subscription using your plan ID and return the subscription ID to the front end:
// POST /api/subscriptions/create
app.post("/api/subscriptions/create", async (req, res) => {
try {
const accessToken = await getAccessToken();
const response = await fetch(
`${PAYPAL_BASE}/v1/billing/subscriptions`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
Prefer: "return=representation",
},
body: JSON.stringify({
plan_id: process.env.PAYPAL_PLAN_ID,
}),
}
);
const data = await response.json();
if (!response.ok || !data.id) {
console.error("Subscription creation failed:", response.status, data);
return res.status(502).json({ error: "subscription_create_error" });
}
// Replace with your own session handling. Associate the subscription with
// the signed-in customer so the lookup endpoint can enforce ownership.
// Track every subscription this session creates, so a second checkout in
// another tab doesn't overwrite the first.
req.session.subscriptionIds = [...(req.session.subscriptionIds ?? []), data.id];
res.json({ subscriptionId: data.id });
} catch (error) {
console.error("Subscription creation error:", error);
res.status(500).json({ error: "subscription_create_error" });
}
});After onApprove fires, verify the subscription is active before fulfilling.
// GET /api/subscriptions/:subscriptionId
app.get("/api/subscriptions/:subscriptionId", async (req, res) => {
try {
// Replace with your own authentication and ownership check. Only the
// customer who created this subscription may look it up.
if (!req.session?.subscriptionIds?.includes(req.params.subscriptionId)) {
return res.status(404).json({ error: "subscription_not_found" });
}
const accessToken = await getAccessToken();
const response = await fetch(
`${PAYPAL_BASE}/v1/billing/subscriptions/${encodeURIComponent(req.params.subscriptionId)}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
}
);
const data = await response.json();
if (!response.ok) {
console.error("Subscription verification failed:", response.status, data);
return res.status(502).json({ error: "subscription_verify_error" });
}
// Return only what the browser needs
res.json({ status: data.status });
} catch (error) {
console.error("Subscription verification error:", error);
res.status(500).json({ error: "subscription_verify_error" });
}
});Serve your index.html, app.js, and success.html from a public folder, then start the server. The extensions option lets /success resolve to success.html:
app.use(express.static("public", { extensions: ["html"] }));
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => console.log(`Listening on port ${PORT}`));Create public/success.html. The buyer lands here after the server confirms the subscription is ACTIVE:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Subscription active</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<h1>Subscription active</h1>
<p>Thanks for subscribing.</p>
</body>
</html>Apply these safeguards when you handle subscription data server-side:
onApprove data.subscriptionId in your system and associate it with the subscriber for lifecycle management.status. Don't forward the full PayPal subscription record.Test the following scenarios in the PayPal sandbox environment before going live:
| Scenario | Expected result |
|---|---|
| Successful subscription | onApprove fires with subscriptionId, server verify returns status: "ACTIVE". |
| Buyer cancels | onCancel fires, non-blocking message shown, retry available. |
| Payment error | onError fires, error logged server-side, user-facing message shown. |
| Invalid plan ID | Server returns error on subscription creation, front end receives rejection. |
Use sandbox test accounts to simulate buyer interactions. Ensure your sandbox PAYPAL_PLAN_ID is set to an active plan in the sandbox environment.
Confirm each of these items before you switch to production:
https://www.paypal.com/web-sdk/v6/core.api-m.sandbox.paypal.com) with production (api-m.paypal.com).PAYPAL_PLAN_ID with your production plan ID.paymentFlow: "RECURRING_PAYMENT") is in place before rendering the button.onError and onCancel handlers provide user-facing recovery paths.The v6 SDK changes several patterns from v5:
| Area | v5 | v6 |
|---|---|---|
| Auth | The client-id query parameter in the script src | The clientId value, fetched from your server and passed to the createInstance() method |
| Script parameters | The components=buttons, vault=true, and intent=subscription parameters in the script src | No subscription parameters in the script src. Pass components: ["paypal-subscriptions"] to createInstance(). |
| Button rendering | The paypal.Buttons({ createSubscription, onApprove }).render() call | Web component: <paypal-button type="subscribe"> |
| Session | Callbacks passed to paypal.Buttons() | The createPayPalSubscriptionPaymentSession() method |
| Subscription creation | The actions.subscription.create({ plan_id }) method inside the createSubscription callback | Your server calls the Subscriptions API. Pass a Promise that resolves to { subscriptionId } to session.start(). |
| Approval data | The data.subscriptionID property | The data.subscriptionId property, and the data.payerId property when available |