How to Integrate a Payment Gateway Into a Custom Website: Stripe and PayPal Setup Explained

If you build bespoke websites, you eventually hit the same wall: the client wants to take money, and there is no Shopify or WooCommerce plugin to lean on. You have to integrate a payment gateway into a website yourself, at the code level, without leaking card data or breaking compliance rules.

This guide is the walkthrough we wish existed when we wired our first custom checkout. No marketing fluff, no “contact a development team” hand-waving. Just the actual steps: API keys, server-side session creation, webhook verification, idempotency, test cards, and the PCI basics that keep you out of trouble.

Who This Guide Is For

  • Developers building a custom site (Next.js, Laravel, Django, Rails, plain PHP, static site + serverless functions).
  • Agencies delivering a one-off booking, donation, subscription or product checkout.
  • Anyone who needs payments working safely, not just working.

If you are on WooCommerce or Shopify, you do not need this article. Install the official extension and move on.

First, Understand What Actually Happens During a Payment

A lot of broken integrations come from developers misunderstanding the flow. Here is the simplified version for a modern hosted-fields or redirect integration:

  1. The browser asks your server to create a payment intent or checkout session (amount, currency, line items).
  2. Your server calls the gateway API using the secret key and returns a token, client secret or redirect URL.
  3. The customer enters card details directly into the gateway’s iframe or hosted page, never into your DOM.
  4. The gateway talks to the acquiring bank and card network, then returns an approval or decline.
  5. The gateway fires a webhook to your server. This is the only event you should trust to fulfil the order.

Rule number one: never mark an order as paid based on a browser redirect alone. Redirects can be faked, cancelled or lost when a user closes the tab.

online payment checkout

Stripe vs PayPal: Which Gateway for a Custom Build?

Most custom projects end up offering both. Stripe handles cards, wallets and local methods; PayPal captures buyers who refuse to type a card number into an unfamiliar site.

Criteria Stripe PayPal
Developer experience Excellent SDKs, clean docs, strong CLI for local webhook testing Good REST API (Orders v2), docs are more fragmented
Fastest safe integration Stripe Checkout (hosted page) or Payment Element JS SDK buttons + server-side create/capture order
Subscriptions Native Billing product, proration, dunning included Subscriptions API, less flexible
Wallets Apple Pay, Google Pay, Link, many local methods PayPal balance, Venmo (US), Pay Later
Typical online rate Around 2.9% + fixed fee per successful card charge (varies by country) Around 3.49% + fixed fee for standard checkout (varies by country)
PCI scope if done right SAQ A (hosted Checkout) or SAQ A with Elements SAQ A with hosted buttons

Our recommendation for bespoke builds: start with Stripe Checkout or Payment Element, add PayPal Smart Buttons as a secondary method. Both keep card data out of your server entirely.

online payment checkout

Prerequisites Before You Write a Line of Code

  • A server-side runtime you control (Node, PHP, Python, Ruby, or serverless functions). A pure static site cannot do this safely.
  • HTTPS everywhere, including on staging. Gateways will refuse to load payment fields on insecure origins.
  • Environment variable storage (.env file excluded from git, or your host’s secret manager).
  • A publicly reachable URL for webhooks, or a tunnelling tool for local development.

Part 1: Integrate Stripe Into a Custom Website

Step 1. Create the account and grab your API keys

Sign up at the Stripe Dashboard, then open Developers > API keys. You get two pairs:

  • Publishable key (pk_test_... / pk_live_...): safe to expose in the browser.
  • Secret key (sk_test_... / sk_live_...): server only. If it ever touches client-side JS, a git repo or a Slack message, roll it immediately.

Store them as environment variables:

STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx

Also create a restricted key for any microservice that only needs read access. Least privilege applies to payments more than anywhere else.

Step 2. Install the server SDK

# Node
npm install stripe

# PHP
composer require stripe/stripe-php

# Python
pip install stripe

Step 3. Create the checkout session server-side

The single most important security rule: the price must come from your database or from a Stripe Price ID, never from the request body. If the client sends the amount, a user will send 1 cent. A capable web development agency builds this in rather than bolting it on.

// server.js (Node + Express)
const express = require('express');
const Stripe = require('stripe');

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.post('/api/checkout-session', express.json(), async (req, res) => {
  try {
    const { productSlug, quantity } = req.body;

    // Look up the real price on your side
    const product = await db.products.findBySlug(productSlug);
    if (!product) return res.status(404).json({ error: 'Unknown product' });

    const qty = Math.min(Math.max(parseInt(quantity, 10) || 1, 1), 10);

    const session = await stripe.checkout.sessions.create({
      mode: 'payment',
      line_items: [{
        price_data: {
          currency: 'eur',
          unit_amount: product.priceInCents, // integer, minor units
          product_data: { name: product.name }
        },
        quantity: qty
      }],
      client_reference_id: req.session.userId,
      metadata: { order_id: product.id, source: 'custom-site' },
      success_url: 'https://yoursite.com/thanks?session_id={CHECKOUT_SESSION_ID}',
      cancel_url: 'https://yoursite.com/cart'
    }, {
      idempotencyKey: req.headers['x-idempotency-key']
    });

    res.json({ url: session.url });
  } catch (err) {
    console.error('Stripe session error', err);
    res.status(500).json({ error: 'Unable to start checkout' });
  }
});

Two details most tutorials skip:

  • Idempotency keys. Send a UUID generated by the client per checkout attempt. If the request is retried after a timeout, Stripe returns the original session instead of creating a duplicate charge.
  • Metadata. Put your internal order ID there. It is what lets your webhook reconcile the payment with your database.

Step 4. Trigger it from the front end

// checkout.js
async function startCheckout(productSlug) {
  const res = await fetch('/api/checkout-session', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-idempotency-key': crypto.randomUUID()
    },
    body: JSON.stringify({ productSlug, quantity: 1 })
  });

  const data = await res.json();
  if (data.url) {
    window.location.assign(data.url);
  } else {
    showError('Payment could not be started. Please try again.');
  }
}

If you prefer the payment form embedded on your own page, swap Checkout for the Payment Element: create a PaymentIntent server-side, return its client_secret, and mount the element with your publishable key. The security model is identical because the card fields live inside a Stripe iframe.

Step 5. Handle webhooks properly

This is where most custom integrations are weak. Your webhook endpoint must:

  1. Read the raw request body (not the parsed JSON) to verify the signature.
  2. Verify the signature with your webhook secret.
  3. Respond with 2xx fast, then do slow work asynchronously.
  4. Be idempotent, because Stripe retries and may deliver the same event twice.
app.post('/api/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['stripe-signature'];
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        signature,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.warn('Invalid webhook signature', err.message);
      return res.status(400).send('Invalid signature');
    }

    // Deduplicate: store processed event IDs
    const alreadySeen = await db.webhookEvents.exists(event.id);
    if (alreadySeen) return res.json({ received: true });
    await db.webhookEvents.insert(event.id);

    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object;
        if (session.payment_status === 'paid') {
          await fulfilOrder(session.metadata.order_id, session.id);
        }
        break;
      }
      case 'payment_intent.payment_failed':
        await flagFailedPayment(event.data.object);
        break;
      case 'charge.refunded':
        await revokeAccess(event.data.object);
        break;
      default:
        break;
    }

    res.json({ received: true });
  }
);

Watch out: if you register express.json() globally before this route, signature verification will always fail. Mount the raw parser on the webhook route specifically.

Test locally with the Stripe CLI:

stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed

Step 6. Test with the sandbox

Scenario Test card
Successful payment 4242 4242 4242 4242
Requires 3-D Secure authentication 4000 0025 0000 3155
Generic decline 4000 0000 0000 0002
Insufficient funds 4000 0000 0000 9995

Use any future expiry date and any 3-digit CVC. Always test the 3-D Secure path: in Europe, Strong Customer Authentication means a large share of live transactions will go through that challenge.

online payment checkout

Part 2: Add PayPal to the Same Custom Site

PayPal’s modern approach uses the Orders v2 REST API with server-side create and capture calls, plus the JS SDK for the buttons.

Step 1. Create an app and get credentials

In the PayPal Developer Dashboard, create an app under Apps & Credentials. You get a Client ID (public) and a Client Secret (server only), in both sandbox and live environments.

PAYPAL_CLIENT_ID=xxxxxxxx
PAYPAL_CLIENT_SECRET=xxxxxxxx
PAYPAL_API=https://api-m.sandbox.paypal.com   # live: https://api-m.paypal.com
PAYPAL_WEBHOOK_ID=xxxxxxxx

Step 2. Get an access token

async function getAccessToken() {
  const credentials = Buffer
    .from(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`)
    .toString('base64');

  const response = await fetch(`${process.env.PAYPAL_API}/v1/oauth2/token`, {
    method: 'POST',
    headers: {
      Authorization: `Basic ${credentials}`,
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: 'grant_type=client_credentials'
  });

  const data = await response.json();
  return data.access_token; // cache it, it lives several hours
}

Step 3. Create and capture the order server-side

// Create order
app.post('/api/paypal/orders', express.json(), async (req, res) => {
  const product = await db.products.findBySlug(req.body.productSlug);
  const token = await getAccessToken();

  const response = await fetch(`${process.env.PAYPAL_API}/v2/checkout/orders`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
      'PayPal-Request-Id': req.headers['x-idempotency-key']
    },
    body: JSON.stringify({
      intent: 'CAPTURE',
      purchase_units: [{
        reference_id: String(product.id),
        amount: {
          currency_code: 'EUR',
          value: (product.priceInCents / 100).toFixed(2)
        }
      }]
    })
  });

  const order = await response.json();
  res.status(response.status).json({ id: order.id });
});

// Capture order
app.post('/api/paypal/orders/:orderId/capture', async (req, res) => {
  const token = await getAccessToken();

  const response = await fetch(
    `${process.env.PAYPAL_API}/v2/checkout/orders/${req.params.orderId}/capture`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`
      }
    }
  );

  const result = await response.json();
  if (result.status === 'COMPLETED') {
    await fulfilOrder(result.purchase_units[0].reference_id, result.id);
  }
  res.status(response.status).json(result);
});

Step 4. Render the buttons

<div id="paypal-button-container"></div>
<script src="https://www.paypal.com/sdk/js?client-id=YOUR_CLIENT_ID&currency=EUR"></script>
<script>
  paypal.Buttons({
    createOrder: () => fetch('/api/paypal/orders', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-idempotency-key': crypto.randomUUID()
      },
      body: JSON.stringify({ productSlug: 'annual-plan' })
    }).then(r => r.json()).then(d => d.id),

    onApprove: (data) => fetch(`/api/paypal/orders/${data.orderID}/capture`, {
      method: 'POST'
    }).then(r => r.json()).then(() => {
      window.location.assign('/thanks');
    }),

    onError: (err) => {
      console.error(err);
      document.getElementById('payment-error').textContent =
        'Payment failed. Please try another method.';
    }
  }).render('#paypal-button-container');
</script>

Step 5. Verify PayPal webhooks

PayPal does not use an HMAC header like Stripe. You post the headers and event body to their verification endpoint:

app.post('/api/webhooks/paypal', express.json(), async (req, res) => {
  const token = await getAccessToken();

  const verify = await fetch(
    `${process.env.PAYPAL_API}/v1/notifications/verify-webhook-signature`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`
      },
      body: JSON.stringify({
        auth_algo: req.headers['paypal-auth-algo'],
        cert_url: req.headers['paypal-cert-url'],
        transmission_id: req.headers['paypal-transmission-id'],
        transmission_sig: req.headers['paypal-transmission-sig'],
        transmission_time: req.headers['paypal-transmission-time'],
        webhook_id: process.env.PAYPAL_WEBHOOK_ID,
        webhook_event: req.body
      })
    }
  );

  const { verification_status } = await verify.json();
  if (verification_status !== 'SUCCESS') {
    return res.status(400).send('Invalid webhook');
  }

  if (req.body.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
    await fulfilOrderFromPayPal(req.body.resource);
  }

  res.sendStatus(200);
});

Subscribe at minimum to PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.DENIED and PAYMENT.CAPTURE.REFUNDED.

online payment checkout

Part 3: PCI Compliance Basics for Custom Sites

You do not get to ignore PCI DSS just because you are small. Every merchant accepting cards is in scope. The good news: with a correct integration, your obligations stay light.

Understand which SAQ applies to you

Integration type Typical SAQ Burden
Full redirect to Stripe Checkout / PayPal hosted page SAQ A Lowest, around 30 questions
Gateway iframe or hosted fields embedded in your page SAQ A Low, plus script controls on the payment page
Your own card form posting to a gateway API SAQ A-EP Heavy, quarterly ASV scans, 150+ controls
Card data touching your server or database SAQ D Very heavy, avoid entirely

Practical takeaway: never build your own card input fields. The cost difference between SAQ A and SAQ A-EP is thousands per year in audit and scanning work.

The non-negotiable checklist

  • TLS 1.2 minimum (prefer 1.3) across the whole site, with HSTS enabled.
  • Never log PANs, CVVs, full tokens or raw webhook payloads containing personal data.
  • Never store card numbers. Store the gateway’s customer ID and payment method token instead.
  • Script integrity on the payment page. PCI DSS v4.x requires you to inventory and justify every script that runs on a page where card data is entered, and to detect unauthorised changes. Use a strict Content-Security-Policy and Subresource Integrity hashes.
  • Rotate keys on staff departure and after any suspected exposure.
  • Rate limit your session-creation endpoint. Card testing bots love unprotected /api/checkout-session routes.
  • Enable 3-D Secure where required. In the EEA and UK, SCA is mandatory for most consumer transactions.
  • Separate test and live credentials at the environment level, never with an if statement in code.

Server-side hardening snippets worth adding

// Minimal CSP for a page hosting Stripe Elements
app.use((req, res, next) => {
  res.setHeader('Content-Security-Policy', [
    "default-src 'self'",
    "script-src 'self' https://js.stripe.com https://www.paypal.com",
    "frame-src https://js.stripe.com https://hooks.stripe.com https://www.paypal.com",
    "connect-src 'self' https://api.stripe.com",
    "object-src 'none'",
    "base-uri 'self'"
  ].join('; '));
  next();
});

Common Mistakes We See in Custom Integrations

  1. Trusting the success_url. Fulfilment must happen in the webhook handler.
  2. Accepting the amount from the client. Always recompute totals, taxes and discounts server-side.
  3. Using floats for money. Work in integer minor units and convert only for display.
  4. No idempotency. One slow network retry becomes two charges and a chargeback.
  5. Unverified webhooks. An open endpoint that marks orders paid is a free shop.
  6. Ignoring refunds and disputes. Subscribe to refund and dispute events and revoke access automatically.
  7. Secret keys in the front-end bundle. Audit your build output before launch.
  8. No reconciliation job. Webhooks can be delayed. Run a nightly job that polls pending orders against the gateway API.
online payment checkout

Go-Live Checklist

  • Live API keys stored in the production secret manager only
  • Webhook endpoints registered in live mode with their own signing secrets
  • Business verification completed with the gateway (bank account, identity documents)
  • Statement descriptor configured so customers recognise the charge
  • Refund flow tested end to end
  • 3-D Secure challenge tested on a real card
  • Receipt and confirmation emails firing from the webhook, not the redirect
  • Error states designed: declined card, expired session, network failure
  • Monitoring and alerting on webhook failures and 5xx responses
  • Terms, refund policy and privacy page published and linked at checkout

FAQ

Can you integrate a payment gateway with any website?

Yes, as long as you have a server-side component. A purely static HTML site cannot create checkout sessions securely because it would expose your secret key. The workaround is a serverless function or a payment link generated from the gateway dashboard.

How do I add a payment system to a website without a plugin?

Create a gateway account, store the secret key as an environment variable, build an API route that creates a checkout session with prices from your own database, redirect the customer to the gateway, then fulfil the order from a signature-verified webhook. That is the complete loop, and the code above implements it for both Stripe and PayPal.

How much does it cost to use a payment gateway on a website?

Most modern gateways charge per transaction rather than monthly. Expect roughly 2.9% to 3.5% plus a small fixed fee per successful payment, with extra charges for currency conversion, international cards and chargebacks. Integration cost is separate: a straightforward custom checkout typically takes one to three developer days, more if you need subscriptions, multi-currency or marketplace split payments.

Should I build my own payment gateway?

No. Building a gateway means acquiring bank relationships, card network certification and full PCI DSS Level 1 compliance. What people usually mean is building a custom checkout on top of an existing gateway, which is exactly what this guide covers.

Do I need both Stripe and PayPal?

Not technically, but offering both usually lifts conversion, especially for first-time buyers on an unfamiliar brand. The marginal work is small once your order, webhook and fulfilment layers are gateway-agnostic.

How do I test webhooks on localhost?

Use the Stripe CLI (stripe listen --forward-to) or a tunnelling service such as ngrok or Cloudflare Tunnel to expose your local port over HTTPS, then register that temporary URL in the gateway dashboard. How to Easily Integrate Payment Gateway for Business covers this in more depth.

What happens if a webhook fails?

Gateways retry with exponential backoff for hours or days. Your endpoint should return 2xx quickly and queue the heavy work. Add alerting so a silent failure does not leave paid orders unfulfilled, and back it up with a reconciliation job.

Final Thoughts

To integrate a payment gateway into a website built from scratch, the technical work is modest. The discipline is what matters: prices computed server-side, card fields owned by the gateway, webhooks verified and idempotent, secrets kept out of the browser, and a reconciliation safety net behind it all.

Get those five things right and your bespoke checkout will be more reliable, faster and cheaper to run than most plugin-based stores. See zapier.com for their take.

Need a custom checkout implemented and audited on your own stack? Our team at CSS Gallery Pro builds and reviews payment integrations for bespoke websites. Get in touch and we will scope it with you.