DEV Community

Cover image for How to Create a One-Time Payment Link and Secure Webhook Integration with Stripe
Hamzat Abdul-muizz
Hamzat Abdul-muizz

Posted on

How to Create a One-Time Payment Link and Secure Webhook Integration with Stripe

Introduction

Stripe is an online payment platform for securely accepting credit cards, bank transfers, and more.
This guide demonstrates an end-to-end integration using Stripe Checkout Sessions for one-time payments and a tamper-proof server webhook to confirm transactions.

Terminology: "Payment Link" here refers to the URL returned by a Stripe Checkout Session (not Stripe's no-code "Payment Links" product).


Payment Flow Overview

  1. Client clicks "Pay Now".
  2. Backend creates a unique Checkout Session and returns the session URL.
  3. Client is redirected to Stripe, completes payment, and is redirected to your success URL.
  4. Stripe sends an asynchronous webhook (server-to-server) to your backend.
  5. Backend verifies the webhook signature, updates the database, and fulfills the order.
  6. Client gains access to the purchased resource.

Textual Diagram (ASCII)

Client (browser)
    |
    | Click "Pay Now"
    v
Backend (your server)
    - create Checkout Session -> returns session.url
    |
    | Redirect to Stripe Checkout
    v
Stripe Checkout (Hosted)
    - collects payment
    |                       \
    | Redirect to success_url   POST webhook -> /api/webhook
    v                       /
Client (success_url)       /
                          v
Backend Webhook Handler <-- verifies signature, idempotency, updates DB
    |
    v
Fulfillment / Grant access to client
Enter fullscreen mode Exit fullscreen mode

Quick Setup

1. Install packages

npm install stripe dotenv
Enter fullscreen mode Exit fullscreen mode

2. .env (example)

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
CLIENT_URL=http://localhost:5173
Enter fullscreen mode Exit fullscreen mode

3. Initialize Stripe

// src/utils/stripe.ts
import Stripe from 'stripe';
import 'dotenv/config';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string,);

export default stripe;
Enter fullscreen mode Exit fullscreen mode

Create Checkout Session (Backend Route)

Create a route that makes a Checkout Session and returns session.url:

// routes/payment.ts
import { Request, Response } from 'express';
import stripe from '../utils/stripe';

export const createCheckoutSession = async (req: Request, res: Response) => {
  const { id } = req.body;
  const existingProduct = { id, price: 4000, name: "Hamza's Coffee" };

  try {
    const session = await stripe.checkout.sessions.create({
      mode: 'payment',
      payment_method_types: ['card', 'link'],
      line_items: [{
        price_data: {
          currency: 'usd',
          product_data: { name: existingProduct.name, description: 'Payment for services rendered' },
          unit_amount: existingProduct.price,
        },
        quantity: 1,
      }],
      success_url: `${process.env.CLIENT_URL}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.CLIENT_URL}/payment/cancel`,
      metadata: { product_id: existingProduct.id }
    });

    res.status(201).json({ message: 'Payment Initialized Successfully', data: { url: session.url } });
  } catch (error) {
    res.status(500).json({ message: 'Error initializing payment.' });
  }
};
Enter fullscreen mode Exit fullscreen mode

Webhook: Raw Body & Signature Verification

Define the webhook route before express.json() so the raw body can be used for signature verification.

Server Example

// server.ts
import express from 'express';
import bodyParser from 'body-parser';
import { webhookHandler } from './routes/webhook';
import { createCheckoutSession } from './routes/payment';

const app = express();

// Raw parser for webhook route only
app.post('/api/webhook', bodyParser.raw({ type: 'application/json' }), webhookHandler);

// Then JSON parser for all other routes
app.use(express.json());
app.post('/api/create-payment-link', createCheckoutSession);

app.listen(4000, () => console.log('Server running on port 4000'));
Enter fullscreen mode Exit fullscreen mode

Webhook Handler

// routes/webhook.ts
import { Request, Response } from 'express';
import Stripe from 'stripe';
import stripe from '../utils/stripe';
import dotenv from 'dotenv';
dotenv.config();

export const webhookHandler = async (req: Request, res: Response) => {
  const sig = req.headers['stripe-signature'] as string;
  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err: any) {
    console.error('Webhook signature verification failed:', err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;
    const productId = session.metadata?.product_id;

    if (productId && session.payment_status === 'paid') {
      // IDEMPOTENCY: ensure session.id hasn't been processed already
      // Example: if (!db.processed(session.id)) { db.markProcessed(session.id); grantAccess(productId); }
      console.log(`Payment confirmed for product ID: ${productId}`);
    }
  }

  res.status(200).json({ received: true });
};
Enter fullscreen mode Exit fullscreen mode

Local Testing with Stripe CLI

  • Forward events:
stripe listen --forward-to localhost:4000/api/webhook
Enter fullscreen mode Exit fullscreen mode
  • Copy the whsec_... key from the CLI output into .env as STRIPE_WEBHOOK_SECRET.
  • Trigger event:
stripe trigger checkout.session.completed
Enter fullscreen mode Exit fullscreen mode

Deployment Checklist

  • Deploy backend to production.
  • Register your live webhook URL in Stripe Dashboard (Developers → Webhooks) and subscribe to checkout.session.completed.
  • Set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in your production environment.
  • Ensure idempotency checks on webhook processing.

Rationale / Key Decisions

  • Raw body required so Stripe signature can be verified.
  • Signature verification prevents forged events.
  • Idempotency avoids duplicate fulfillment on retries.
  • Stripe CLI simplifies local testing.

Further Reading


Top comments (0)