On this page
No Headings
Last updated: August 24, 2026
Estimated time: 20 minutes
Adding PayPal to a plain Node.js server doesn't need a framework or a bundler. In this guide you build a small Express app from scratch; one server file and one HTML page. We'll create a PayPal order, let the buyer approve it, and capture the payment. The @paypal/paypal-server-sdk handles the PayPal side on the backend, and the PayPal JavaScript SDK v6 drives checkout in the browser.
Orders are created and captured on the server, with the price held server-side so the browser can never set its own amount. You'll build the client and server sides in step, seeing exactly where each piece plugs in, rather than starting from a finished file.
Want to skip the setup?
Use the Open in GitHub Codespaces button at the top of this page to launch the finished project in your browser, or clone the companion repo on GitHub to follow along locally.
A minimal Express + TypeScript server with one demo product, $9.99 USD, sold through the native <paypal-button> web component on a plain HTML page. By the end, clicking the button creates a Sandbox order, walks the buyer through approval, and captures the payment, with a status message on the page confirming it.

Other PayPal features
PayPal and Pay Later work out of the box, with Venmo, Apple Pay, and cards available via extra components, across 200+ countries and 23+ currencies.
By the end of this guide you'll know how to:
Client and OrdersController from @paypal/paypal-server-sdk on a plain Express server<paypal-button> web component, building the click-to-capture flow one piece at a time| Layer | Technology |
|---|---|
| Frontend | Plain HTML and JavaScript, PayPal JS SDK v6 (loaded at runtime) |
| Backend | Express, TypeScript, @paypal/paypal-server-sdk |
Once you've finished this guide, the project will look like this:
paypal-integration/
├── src/
│ └── index.ts # Express app: settings, PayPal client, routes
├── public/
│ └── index.html # Loads the PayPal SDK, renders <paypal-button>, drives checkout
├── package.json
├── tsconfig.json
└── .env # Your credentials (never commit this)<paypal-button> web component.createOrder().createOrder() calls your /api/orders endpoint, which uses OrdersController to create the order with PayPal and returns the order ID.onApprove callback calls your /api/orders/:orderId/capture endpoint, which captures the order and finalizes the payment.npm install @paypal/paypal-server-sdkThis is the only extra dependency on the server. It wraps the PayPal REST API with types and handles OAuth for you.
PAYPAL_CLIENT_ID=your_sandbox_client_id
PAYPAL_CLIENT_SECRET=your_sandbox_secret
PAYPAL_ENV=sandbox
PORT=3000PAYPAL_ENV toggles between Sandbox and live, and PORT is optional if you're happy with 3000.
Warning
If you are using a version control system like Git, add .env to your .gitignore before you go any further.
PAYPAL_CLIENT_SECRET stays server-side for the life of the app and should never be committed or sent to the browser.
import dotenv from "dotenv";
dotenv.config();
const paypalSettings = {
clientId: process.env.PAYPAL_CLIENT_ID,
clientSecret: process.env.PAYPAL_CLIENT_SECRET,
env: process.env.PAYPAL_ENV as "sandbox" | "live",
};Add this above the line where you create the Express app, const app = express();.
Add three routes you'll fill in as you go:
app.get("/api/config", (_req, res) => {
res.status(500).send();
});
app.post("/api/orders", async (_req, res) => {
res.status(500).send();
});
app.post("/api/orders/:orderId/capture", async (req, res) => {
res.status(500).send();
});/api/configapp.get("/api/config", (_req, res) => {
res.json({
clientId: paypalSettings.clientId,
env: paypalSettings.env,
sdkUrl:
paypalSettings.env === "live"
? "https://www.paypal.com/web-sdk/v6/core"
: "https://www.sandbox.paypal.com/web-sdk/v6/core",
});
});Replace the stubbed handler with this. It hands the browser the public Client ID, the environment, and the matching SDK URL, so switching Sandbox and live is a single environment variable, with no client-side change.
<script>
async function loadPayPalConfig() {
const response = await fetch("/api/config");
return response.json();
}
loadPayPalConfig().then((config) => {
const script = document.createElement("script");
script.src = config.sdkUrl;
script.onload = () => {
// Initialize PayPal buttons here
};
document.head.appendChild(script);
});
</script>Add this inside <body>. It fetches your config, then loads the PayPal SDK script it points to.
<div id="paypal-button-container">
<paypal-button hidden></paypal-button>
</div>
<div id="result"></div><paypal-button> is a web component the PayPal SDK provides. It starts hidden so buyers never see a button before it's ready to handle a click.
script.onload = async () => {
const sdkInstance = await window.paypal.createInstance({
clientId: config.clientId,
components: ["paypal-payments"],
pageType: "checkout",
});
const paypalButton = document.querySelector("paypal-button");
paypalButton.removeAttribute("hidden");
};createInstance takes your Client ID and the components you want, paypal-payments here. Reload http://localhost:3000 and you should see the button. Clicking it won't do anything yet.
const paypalButton = document.querySelector("paypal-button");
paypalButton.addEventListener("click", async () => {
alert("make paypal work");
});
paypalButton.removeAttribute("hidden");Wire up the click handler before you reveal the button, so it's never clickable without one attached. Click the button now, you should see the alert, confirming the handler is live before you build the real payment flow on top of it.
paypalButton.addEventListener("click", async () => {
const paypalPaymentSession = sdkInstance.createPayPalOneTimePaymentSession({
onApprove(data) {
console.log("Payment approved:", data);
},
});
await paypalPaymentSession.start(
{ presentationMode: "auto" }, // Auto-detects best presentation mode
createOrder(),
);
});Replace the alert with this. start() takes a presentation option and a call to createOrder(), which must resolve to an object with an orderId property. That's what kicks off the checkout. Click the button now and check your console: you'll see an error that createOrder is not defined, which the next step fixes.
createOrder on the clientasync function createOrder() {
const response = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemId: "demo-product" }),
});
return response.json(); // Must resolve to { orderId: "..." }
}Add this next to loadPayPalConfig(). It's hardcoded to demo-product here; in a real app you'd pass whichever item the buyer is checking out with.
import { CheckoutPaymentIntent, Client, Environment, OrdersController } from "@paypal/paypal-server-sdk";Add that import alongside your others, then add the stub catalogue and the OrdersController above where you create the Express app:
// A simple in-memory catalogue of items that can be purchased.
const items = new Map([
["demo-product", { amount: { currencyCode: "GBP", value: "9.99" }, description: "Demo product" }],
]);
const ordersController = new OrdersController(
new Client({
clientCredentialsAuthCredentials: {
oAuthClientId: process.env.PAYPAL_CLIENT_ID!,
oAuthClientSecret: process.env.PAYPAL_CLIENT_SECRET!,
},
environment: process.env.PAYPAL_ENV === "live" ? Environment.Production : Environment.Sandbox,
}),
);Prices live in items, not in anything the browser sends, so a buyer can never pay less than the real price.
app.post("/api/orders", async (req, res) => {
const { itemId } = req.body;
const item = items.get(itemId);
if (!item) {
return res.status(400).json({ error: "Invalid item ID" });
}
try {
const { result } = await ordersController.createOrder({
body: {
intent: CheckoutPaymentIntent.Capture,
purchaseUnits: [item],
},
});
res.json({ orderId: result.id });
} catch (err) {
res.status(500).json({ error: "Failed to create order" });
}
});Replace the stubbed handler with this. It looks the item up by itemId, asks PayPal to create the order, and returns the order id the client is waiting for.
Refresh the page and click the button. You can now complete a Sandbox payment using a PayPal test card or your Sandbox buyer account, though the payment won't be captured until the next two steps.
app.post("/api/orders/:orderId/capture", async (req, res) => {
try {
const { orderId } = req.params;
const { result } = await ordersController.captureOrder({ id: orderId });
res.status(200).json(result);
} catch (err) {
res.status(500).json({ error: "Failed to capture order" });
}
});Replace the stubbed capture handler with this. Approving an order doesn't move any money on its own, capturing it does.
const paypalPaymentSession = sdkInstance.createPayPalOneTimePaymentSession({
onApprove: async (data) => {
const response = await fetch(`/api/orders/${data.orderId}/capture`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const responseData = await response.json();
document.getElementById("result").textContent =
responseData.status === "COMPLETED"
? "Payment completed successfully!"
: `Payment status: ${responseData.status}`;
},
});Replace the onApprove you added earlier with this. Now, when a buyer approves the payment, the client calls your capture endpoint and shows the result on the page.
Refresh http://localhost:3000, click the PayPal button, and complete a payment with your Sandbox buyer account, found in the PayPal Developer Dashboard under Sandbox → Accounts. To pay by card instead, use one of the test card numbers listed under Sandbox → Cards in the Developer Dashboard. Any future expiry date and a matching-length CVV are accepted in Sandbox.
You should see "Payment completed successfully!" once the capture finishes.
Tip
Use the Sandbox buyer email and password from the Developer Dashboard to complete a test transaction. Never enter real card details in Sandbox mode.
Being production ready
This is a demo, built step by step for clarity. Before using anything like it in production:
onCancel and onError callbacks, not just onApprove./api/orders and /api/orders/:orderId/capture, instead of a bare 500.Still stuck?
Ask in the PayPal Developer Community, or open an issue on the companion GitHub repo.
The complete project is in the companion GitHub repo, or launch it instantly with the Open in GitHub Codespaces button at the top of this page.
Yes. Rename src/index.ts to src/index.js, remove the type annotations, and drop the tsc build step. Everything else works the same way.
Log in to the PayPal Developer Dashboard, go to Sandbox → Accounts, and use the default personal (buyer) account's generated email and password to complete a test transaction.
Yes. Add more entries to the items map, or look the amount up from a real cart or database before calling createOrder. Never accept the amount from the browser.
Nothing. onApprove only fires after the buyer approves the payment. If they cancel or close the popup, the order stays created but uncaptured, and no money moves.
/api/config?Yes. The Client ID is a public identifier the PayPal JS SDK needs to initialize, and it's safe to send to the browser. PAYPAL_CLIENT_SECRET is different: it never leaves src/index.ts.
Those are additional components layered on top of this one-time-payment button. Start from the JavaScript SDK reference to see which components to load for each method.