Build a Stripe Payment API

Build a payment API with idempotency keys and webhook verification.

Introduction

30 Second Summary

You tap "Buy" on your phone, the screen freezes for a second, you tap again. Behind the scenes, your payment just fired twice. Without the right backend safeguards, that one frustrated tap becomes two charges on your card.

In this project, you will build a payment API that processes charges through Stripe, prevents duplicate charges with idempotency keys, and verifies webhook signatures to reject forged events.

What You'll Build

You will POST a payment request and watch it appear in the Stripe Dashboard, fire the same request twice to prove only one charge is created, then trigger webhook events and see your server reject a tampered payload in real time.

By the end of this project, you'll have:

  • A working payment endpoint where you send a charge request and see a real PaymentIntent appear in your Stripe Dashboard within seconds.
  • Provable idempotency protection that you can test by firing the same request twice with the same key and confirming only one charge exists.
  • A webhook endpoint that verifies Stripe signatures, rejects forged payloads, and logs every payment lifecycle event as structured JSON.
  • Secret Mission: Add rate limiting and a payment retrieval endpoint to protect your API from abuse.

Are there any prerequisites?

This project assumes you are comfortable with Node.js and Express, and familiar with REST APIs and async/await. No prior Stripe experience is required.

Before We Start

Before diving into code, let's get clear on what you're building and why it matters. Payment systems handle real money, so understanding the consequences of duplicate charges will shape every design decision you make.

Set Up Your Project

Before you can process any payments, you need a working development environment with Stripe credentials and the CLI tool for local webhook testing.

In this step, you will create the project, install your dependencies, connect to Stripe's Test Mode, and confirm everything is wired up with a running server.

In this step, get ready to:
  • Create the project directory, install dependencies, and scaffold the file structure.
  • Set up a Stripe Test Mode account and configure environment variables.
  • Install the Stripe CLI and start a minimal Express server with a health check endpoint.
Create the project and install dependencies

Your project needs a home on your filesystem and a set of npm packages to power the payment API.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir stripe-payment-api
cd stripe-payment-api
  • Initialize your npm project by running:
npm init -y
  • Confirm the folder and package.json exist by running:
ls

You should see package.json listed in the output.

  • Install the four packages your API needs by running:
npm install stripe@22.3.2 express@4.22.2 zod@4.4.3 dotenv@17.4.2

What are these packages?

  • stripe is the official Stripe SDK for creating and managing payments.
  • express is the web framework that handles HTTP requests and routing.
  • zod is a schema validation library that rejects bad input before it reaches Stripe.
  • dotenv loads environment variables from a .env file so you don't hardcode secrets.
  • Confirm the install succeeded by running:
ls node_modules/.package-lock.json 2>/dev/null && echo 'Install OK' || echo 'Something went wrong'

You should see Install OK in the output.

Install failed or timed out?

  • Make sure you are inside the stripe-payment-api folder. Run pwd to check.
  • If you see permission errors, try running with sudo (macOS/Linux) or opening your terminal as Administrator (Windows).

Need more help? Help me debug my npm install

  • Create the folder structure for your source files by running:
mkdir -p src/lib src/middleware src/routes
  • Create the empty files that will hold your application code by running:
touch src/server.js src/config.js src/lib/stripe.js src/lib/logger.js src/middleware/validate.js src/routes/payments.js src/routes/webhooks.js
  • Verify the file structure by running:
find src -type f

You should see all seven files listed: src/server.js, src/config.js, src/lib/stripe.js, src/lib/logger.js, src/middleware/validate.js, src/routes/payments.js, and src/routes/webhooks.js.

Connect your Stripe Test Mode credentials

Stripe's Test Mode lets you process fake payments without any real charges. You need a secret key from your Stripe account to authenticate API calls.

  • Open stripe.com in your browser.
  • Create a free account or sign in to an existing one.
  • In the top-left corner, confirm the toggle reads Test mode (orange label). This ensures no real money is involved.
  • Click Developers in the top-right navigation bar.
  • Click API keys in the left sidebar.
  • Under Standard keys, find the Secret key row (it starts with sk_test_). Click Reveal test key and copy the full value.

What is a Secret Key?

Your secret key authenticates your server with Stripe's API. It starts with sk_test_ in Test Mode, meaning it can only create test charges. Never commit it to version control or share it publicly.

  • Open the stripe-payment-api folder in Cursor or VS Code.
  • Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the command palette.
  • Type Terminal and select Terminal: Create New Terminal if you don't already have one open.
  • Create a .env file in the project root. Paste the following, replacing the placeholder with your copied secret key:
STRIPE_SECRET_KEY=sk_test_your_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
PORT=4242

Why not use a variable placeholder for the key?

API keys are sensitive credentials. Replace sk_test_your_key_here with your actual test key from the Stripe Dashboard. Leave STRIPE_WEBHOOK_SECRET as-is for now. You will get the real value in a later step.

  • Create a .env.example file so collaborators know which variables are expected:
STRIPE_SECRET_KEY=sk_test_your_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
PORT=4242
  • Open src/config.js and add the following code that loads and validates environment variables:
require('dotenv').config({ quiet: true });

const config = {
  stripe: {
    secretKey: process.env.STRIPE_SECRET_KEY,
    webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
  },
  port: parseInt(process.env.PORT, 10) || 4242,
};

if (!config.stripe.secretKey) {
  console.error('Missing STRIPE_SECRET_KEY environment variable');
  process.exit(1);
}

module.exports = config;

What does this code do?

  • require('dotenv').config({ quiet: true }) reads your .env file and loads the values into process.env. The quiet flag suppresses informational messages from dotenv.
  • The if (!config.stripe.secretKey) block stops the server immediately if the key is missing, preventing confusing errors later.
  • port defaults to 4242 if PORT is not set in the environment.
  • Open src/lib/stripe.js and add the Stripe SDK initialization:
const Stripe = require('stripe');
const config = require('../config');

const stripe = new Stripe(config.stripe.secretKey);

module.exports = stripe;

What does this code do?

This creates a single Stripe client instance that every route will share. Using new Stripe() is required in SDK version 22+. The older function-call style no longer works.

  • Save both src/config.js and src/lib/stripe.js.
Install the Stripe CLI and start the server

The Stripe CLI lets you forward webhook events to your local machine during development. You will also use it to trigger test events later in the project.

  • Check whether the Stripe CLI is already installed by running:
stripe --version

✔️ I see a version number

The Stripe CLI is already installed. Skip to the authentication step below.

ⓧ Command not found

You need to install the Stripe CLI. Follow the instructions for your operating system below.

macOS

  • Install the Stripe CLI via Homebrew by running:
brew install stripe

Once the install completes, run stripe --version to confirm it works.

Windows

  • Install the Stripe CLI via winget by running:
winget install Stripe.StripeCLI

Once the install completes, open a new terminal window and run stripe --version to confirm it works.

Homebrew not found on macOS?

If you see a "command not found" error for brew, install Homebrew first from brew.sh. Then re-run the brew install stripe command.

Still stuck? Help me install the Stripe CLI

  • Authenticate the CLI with your Stripe account by running:
stripe login

This opens your browser to a Stripe authorization page. Click Allow access to link the CLI with your test account. Return to the terminal and you should see a confirmation message.

What does stripe login do?

It pairs your terminal session with your Stripe account so CLI commands (like forwarding webhook events) use your test credentials automatically. The pairing persists until you explicitly log out.

Now build the minimal Express server that proves your environment is wired up correctly.

  • Open src/lib/logger.js and add a placeholder export so the server file can import it without errors:
function createLogEntry(level, message, meta = {}) {
  return JSON.stringify({
    timestamp: new Date().toISOString(),
    level,
    message,
    ...meta,
  });
}

const logger = {
  info(message, meta) {
    console.log(createLogEntry('info', message, meta));
  },
  error(message, meta) {
    console.error(createLogEntry('error', message, meta));
  },
  warn(message, meta) {
    console.warn(createLogEntry('warn', message, meta));
  },
};

module.exports = logger;

What does this code do?

The logger outputs structured JSON instead of plain strings. Each log entry includes a timestamp, severity level, and optional metadata. This format is machine-parseable, which matters when you need to search logs in production.

  • Open src/routes/payments.js and add a minimal placeholder so the server can import it:
const express = require('express');
const router = express.Router();

module.exports = router;
  • Open src/routes/webhooks.js and add a minimal placeholder:
const express = require('express');
const router = express.Router();

module.exports = router;
  • Open src/middleware/validate.js and add a placeholder:
function validate(schema) {
  return (req, res, next) => {
    next();
  };
}

module.exports = validate;
  • Open src/server.js and add the Express server with a /health endpoint:
const config = require('./config');
const express = require('express');
const logger = require('./lib/logger');
const paymentsRouter = require('./routes/payments');
const webhooksRouter = require('./routes/webhooks');

const app = express();

app.use('/webhooks', webhooksRouter);

app.use(express.json());

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.use('/payments', paymentsRouter);

app.use((err, req, res, next) => {
  logger.error('Unhandled error', { error: err.message, stack: err.stack });
  res.status(500).json({ error: 'Internal server error' });
});

app.listen(config.port, () => {
  logger.info(`Payment API running on port ${config.port}`);
});

What does this code do?

  • The /webhooks route is registered BEFORE express.json(). This is critical because Stripe webhooks need the raw request body to verify signatures. Parsing it as JSON first would break verification.
  • The /health endpoint returns a simple JSON response to confirm the server is alive.
  • The error handler at the bottom catches any unhandled errors and returns a clean 500 response instead of leaking stack traces.
  • Save all files.

Before you run the server, what do you think the terminal will print if your .env file is configured correctly?

  • Start the server by running:
node src/server.js

You should see a structured JSON log entry with the message Payment API running on port 4242 in the terminal.

  • Open a second terminal (Cmd+Shift+P on macOS or Ctrl+Shift+P on Windows, then select Terminal: Create New Terminal) and test the health endpoint:
curl http://localhost:4242/health

You should see {"status":"ok"} returned in the terminal.

Seeing 'Missing STRIPE_SECRET_KEY' and the process exits?

  • Make sure your .env file is in the project root (stripe-payment-api/.env), not inside the src/ folder.
  • Confirm the STRIPE_SECRET_KEY line has no extra spaces around the = sign.
  • Make sure you replaced sk_test_your_key_here with your actual key from the Stripe Dashboard.

Still stuck? Help me debug my server startup

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

Scroll back up and confirm each file is present in order:

  • src/config.js loads and validates environment variables.
  • src/lib/stripe.js initializes the Stripe SDK.
  • src/lib/logger.js outputs structured JSON logs.
  • src/routes/payments.js exports an empty router (for now).
  • src/routes/webhooks.js exports an empty router (for now).
  • src/middleware/validate.js exports a pass-through function (for now).
  • src/server.js wires everything together with a /health endpoint.

Your environment is ready and connected to Stripe. Next up, you will build the payment endpoint that creates real PaymentIntents in your test account.

Create Your First Payment

Your server is running and the health check proves your Stripe credentials are wired in correctly. Now it is time to make it actually do something useful.

In this step, you will build the core payment route that creates a Stripe PaymentIntent and a confirmation route that charges the card. By the end, you will see a real PaymentIntent appear in your Stripe Dashboard.

In this step, get ready to:
  • Build a POST route that creates a PaymentIntent through the Stripe SDK.
  • Add a confirmation route that charges a test card.
  • Test both success and decline scenarios with Stripe test PaymentMethod IDs.
Build the payment creation route

The Payment Intents API is Stripe's recommended way to handle charges. You create a PaymentIntent with an amount and currency, then confirm it separately with a payment method.

  • Open src/routes/payments.js in your editor and replace any existing content with the following code:
const express = require('express');
const stripe = require('../lib/stripe');
const router = express.Router();

// Create a new PaymentIntent
router.post('/', async (req, res) => {
  const { amount, currency, description } = req.body;
  try {
    const paymentIntent = await stripe.paymentIntents.create({
      amount,
      currency,
      description,
      // Only allow non-redirect payment methods for server-side testing
      automatic_payment_methods: {
        enabled: true,
        allow_redirects: 'never',
      },
    });
    res.status(201).json({
      id: paymentIntent.id,
      status: paymentIntent.status,
      amount: paymentIntent.amount,
      currency: paymentIntent.currency,
      clientSecret: paymentIntent.client_secret,
    });
  } catch (error) {
    if (error.type === 'StripeInvalidRequestError') {
      return res.status(400).json({ error: error.message });
    }
    res.status(500).json({ error: 'Payment creation failed' });
  }
});

module.exports = router;

What does this code do?

This route handles POST requests to /payments. It pulls amount and currency from the request body and passes them to Stripe's PaymentIntents API.

The automatic_payment_methods option with allow_redirects: 'never' restricts Stripe to card-based methods only. This keeps testing simple because you will not need a browser-based redirect flow.

The response returns the PaymentIntent's ID, status, amount, currency, and client secret. At this point the status is requires_payment_method because no card has been attached yet.

  • Save src/routes/payments.js.
  • Stop your running server with Ctrl+C, then restart it by running:
node src/server.js
  • Test the new route by running this curl command in a second terminal:
curl -X POST http://localhost:4242/payments -H "Content-Type: application/json" -d '{"amount":2000,"currency":"usd"}'

You should see a JSON response with a status of requires_payment_method and an id starting with pi_.

Seeing "Cannot POST /payments"?

  • Make sure you restarted the server after saving. The --watch flag in npm run dev auto-restarts, but node src/server.js does not.
  • Confirm that src/server.js has the line app.use('/payments', paymentsRouter); and that paymentsRouter is required from ./routes/payments.
  • help me debug a Cannot POST /payments error in my Express Stripe API
Add the payment confirmation route

Creating a PaymentIntent only reserves the charge. To actually process the payment, you need to confirm it with a specific payment method.

  • In src/routes/payments.js, find the line that says module.exports = router; and add the following route above it:
// Confirm a PaymentIntent with a payment method
router.post('/confirm', async (req, res) => {
  const { paymentIntentId, paymentMethod } = req.body;
  try {
    const paymentIntent = await stripe.paymentIntents.confirm(
      paymentIntentId,
      { payment_method: paymentMethod }
    );
    res.json({
      id: paymentIntent.id,
      status: paymentIntent.status,
      amount: paymentIntent.amount,
      currency: paymentIntent.currency,
    });
  } catch (error) {
    // Handle card declines separately from other errors
    if (error.type === 'StripeCardError') {
      return res.status(402).json({
        error: 'Payment declined',
        declineCode: error.decline_code,
        message: error.message,
      });
    }
    if (error.type === 'StripeInvalidRequestError') {
      return res.status(400).json({ error: error.message });
    }
    res.status(500).json({ error: 'Payment confirmation failed' });
  }
});

What does this code do?

This route takes a paymentIntentId and a paymentMethod and tells Stripe to process the charge. Stripe provides test PaymentMethod IDs like pm_card_visa that simulate real cards without moving money.

The error handling distinguishes card declines (402 status) from invalid requests (400 status). When a card is declined, the response includes the specific declineCode so your frontend can show the user exactly what went wrong.

  • Save src/routes/payments.js and restart your server.
Test the full payment flow

Now you will run the complete two-step payment flow: create a PaymentIntent, then confirm it. You will test both a successful charge and a declined card.

  • Create a PaymentIntent by running:
curl -X POST http://localhost:4242/payments -H "Content-Type: application/json" -d '{"amount":2000,"currency":"usd"}'

Copy the id value from the response (it starts with pi_). You will use it in the next command.

  • Confirm the payment with the test Visa card by running this command, replacing the PaymentIntent ID with yours:
curl -X POST http://localhost:4242/payments/confirm -H "Content-Type: application/json" -d '{"paymentIntentId":"[[PI_ID=\"your pi_ ID from the previous response\"]]","paymentMethod":"pm_card_visa"}'

You should see a response with status: "succeeded". The charge went through on Stripe's test network.

Before you run the next command, what do you think will happen when you try to confirm a new PaymentIntent with a card that gets declined?

  • Create another PaymentIntent by running the same create command again:
curl -X POST http://localhost:4242/payments -H "Content-Type: application/json" -d '{"amount":2000,"currency":"usd"}'
  • Copy the new id and confirm it with the decline test card:
curl -X POST http://localhost:4242/payments/confirm -H "Content-Type: application/json" -d '{"paymentIntentId":"[[PI_ID2=\"your new pi_ ID\"]]","paymentMethod":"pm_card_visa_chargeDeclined"}'

This time you get a 402 response with declineCode: "generic_decline" and the error message from Stripe. Your API correctly distinguishes between successful and declined payments.

  • Open your Stripe Dashboard in a browser.
  • Make sure you are in Test mode (the toggle in the top-right should say Test mode).
  • Click Payments in the left sidebar.
  • Confirm you see the PaymentIntents you just created, one with status Succeeded and one with status Failed.

Not seeing your payments in the Dashboard?

  • Make sure Test mode is enabled. Payments made with sk_test_ keys only appear in Test mode.
  • Check that the curl responses included a pi_ ID. If you saw an error instead, your STRIPE_SECRET_KEY in .env may be incorrect.
  • help me debug why my Stripe test PaymentIntents are not showing in the Dashboard

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/routes/payments.js and confirmed both PaymentIntents appear in the Dashboard.

ⓧ I'd like to double check the full code

Your src/routes/payments.js should contain these sections in order:

  • The express and stripe imports, and router initialization.
  • The router.post('/') handler that calls stripe.paymentIntents.create() with automatic_payment_methods and returns the PaymentIntent fields.
  • The router.post('/confirm') handler that calls stripe.paymentIntents.confirm() and handles StripeCardError with a 402 response.
  • module.exports = router; at the bottom.

Scroll up and confirm each chunk is present. The two route handlers should appear between the router initialization and the module.exports line.

Your payment API can now create charges, confirm them, and handle declines with descriptive errors. Next up, you will discover what happens when network retries send the same request twice.

Expose the Duplicate Charge Problem

In the previous step, you created and confirmed PaymentIntents through your API. Everything works.

But real-world networks are unreliable. Requests time out, mobile connections drop, and client-side retry logic fires automatically. Without any protection against duplicates, a single customer click can silently create multiple charges. In this step, you will witness this problem firsthand and add logging that makes it undeniably visible in your terminal.

In this step, get ready to:
  • Send duplicate payment requests and observe two charges in the Stripe Dashboard.
  • Build a structured JSON logger for your API.
  • Wire logging into your payment route to trace each creation event.
Simulate a network retry with duplicate requests

When a payment request times out in production, the client retries. Your API currently has no way to tell a retry apart from a brand new request. Both look identical.

  • Make sure your server is running. If it is not, start it by running:
  • Keep this terminal open throughout this step.
node src/server.js
  • Open a second terminal window (keep the server running in the first one).
  • Send a payment request by running this command:
curl -X POST http://localhost:4242/payments \
  -H "Content-Type: application/json" \
  -d '{"amount":2000,"currency":"usd"}'

You will see a response with a PaymentIntent ID starting with pi_ and a status of requires_payment_method.

  • Now simulate a retry. Run the exact same command again:
curl -X POST http://localhost:4242/payments \
  -H "Content-Type: application/json" \
  -d '{"amount":2000,"currency":"usd"}'

Before you check the Dashboard, how many PaymentIntents do you think were created? One (because it is the same request) or two (because the API cannot tell them apart)?

  • Open your Stripe Dashboard in the browser.
  • Make sure you are in Test Mode (the toggle in the Dashboard header).
  • Click Payments in the left sidebar.

You will see TWO separate PaymentIntents listed, both for $20.00 USD. Each has a different pi_ ID. In production, this means a customer just got charged twice for one purchase.

Why does this happen?

Stripe treats every API call as a unique request. It has no way to know that your second call was a retry of the first. Without an explicit signal from your code, Stripe creates a new PaymentIntent every time.

This is the exact bug that idempotency keys solve. You will fix it in the next step.

Build the structured logger

Right now, you have no visibility into what your API does. Adding structured JSON logging gives you machine-parseable output that proves each request creates a separate PaymentIntent.

  • Open src/lib/logger.js in your editor and replace any existing content with the following:
// Formats a structured log entry as a JSON string
function createLogEntry(level, message, meta = {}) {
  return JSON.stringify({
    timestamp: new Date().toISOString(),
    level,
    message,
    ...meta,
  });
}

// Logger object with methods for each severity level
const logger = {
  info(message, meta) {
    console.log(createLogEntry('info', message, meta));
  },
  error(message, meta) {
    console.error(createLogEntry('error', message, meta));
  },
  warn(message, meta) {
    console.warn(createLogEntry('warn', message, meta));
  },
};

module.exports = logger;

What does this code do?

  • createLogEntry builds a JSON string with a timestamp, severity level, a human-readable message, and any extra metadata you pass in.
  • The logger object exposes three methods (info, error, warn) that write to the appropriate console stream.
  • Every log line is valid JSON, making it easy to search, filter, and pipe into monitoring tools later.
  • Save src/lib/logger.js.
  • Stop your server with Ctrl+C and restart it:
node src/server.js

Your startup message now appears as structured JSON instead of plain text. You should see something like:

{"timestamp":"2026-07-16T...","level":"info","message":"Payment API running on port 4242"}

Still seeing plain text or an error?

  • Make sure you saved src/lib/logger.js before restarting the server.
  • Check that module.exports = logger is at the bottom of the file. Without the export, the server cannot import it.

Help me debug my logger setup.

Wire the logger into your payments route

The logger works, but it is not watching your payment creations yet. You need to import it into the payments route and log each PaymentIntent as it is created.

  • In src/routes/payments.js, find these lines at the top of the file:
const express = require('express');
const stripe = require('../lib/stripe');
  • Add the logger import directly below them, so the top of the file now looks like this:
const express = require('express');
const stripe = require('../lib/stripe');
const logger = require('../lib/logger');
  • Next, find the line inside the router.post('/') handler where the PaymentIntent response is sent. Look for:
    res.status(201).json({
  • Add the following logging call directly ABOVE that line (after the paymentIntents.create() call, before the response):
    logger.info('PaymentIntent created', {
      paymentIntentId: paymentIntent.id,
      amount,
      currency,
    });
  • Finally, find the catch block in the same route handler. Look for:
  } catch (error) {
    if (error.type === 'StripeInvalidRequestError') {
  • Add an error log line between the catch (error) { line and the if statement, so the catch block starts with:
  } catch (error) {
    logger.error('Payment creation failed', {
      error: error.message,
      type: error.type,
    });
    if (error.type === 'StripeInvalidRequestError') {

What do these additions do?

  • The logger.info call writes a structured JSON entry every time a PaymentIntent is successfully created, including its ID, amount, and currency.
  • The logger.error call captures failed creation attempts with the error message and type, giving you visibility into problems without needing to reproduce them.
  • Save src/routes/payments.js.
  • Stop your server with Ctrl+C and restart it:
node src/server.js

Now send the same duplicate request pair again. What do you expect to see in the server terminal this time?

  • In your second terminal, run the payment request twice in quick succession:
curl -X POST http://localhost:4242/payments \
  -H "Content-Type: application/json" \
  -d '{"amount":2000,"currency":"usd"}'
curl -X POST http://localhost:4242/payments \
  -H "Content-Type: application/json" \
  -d '{"amount":2000,"currency":"usd"}'

Switch back to your server terminal. You will see TWO separate structured log entries, each with a different paymentIntentId but the same amount and currency:

{"timestamp":"...","level":"info","message":"PaymentIntent created","paymentIntentId":"pi_abc123","amount":2000,"currency":"usd"}
{"timestamp":"...","level":"info","message":"PaymentIntent created","paymentIntentId":"pi_def456","amount":2000,"currency":"usd"}

Two distinct PaymentIntent IDs for one logical purchase. The duplicate charge problem is now undeniable in both your Dashboard and your logs.

Not seeing structured log output?

  • Confirm you added const logger = require('../lib/logger'); at the top of src/routes/payments.js.
  • Make sure the logger.info(...) call is inside the try block, after the stripe.paymentIntents.create() call but before res.status(201).
  • Double-check you restarted the server after saving your changes.

Help me debug why my payment logs are not appearing.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/lib/logger.js:

function createLogEntry(level, message, meta = {}) {
  return JSON.stringify({
    timestamp: new Date().toISOString(),
    level,
    message,
    ...meta,
  });
}

const logger = {
  info(message, meta) {
    console.log(createLogEntry('info', message, meta));
  },
  error(message, meta) {
    console.error(createLogEntry('error', message, meta));
  },
  warn(message, meta) {
    console.warn(createLogEntry('warn', message, meta));
  },
};

module.exports = logger;

For src/routes/payments.js, confirm these pieces are in place:

  • Line 3: const logger = require('../lib/logger'); (after the stripe import).
  • Inside the try block of router.post('/'): a logger.info('PaymentIntent created', {...}) call between the create call and the response.
  • Inside the catch block of router.post('/'): a logger.error('Payment creation failed', {...}) call before the if statements.

You have proven that your API creates duplicate charges on retries. Your logs now make this visible in real time. In the next step, you will add idempotency keys and request validation to guarantee that retried requests produce exactly one charge.

Add Idempotency Keys and Request Validation

In the last step, you watched the duplicate charge problem happen live. Two identical requests created two separate PaymentIntents. In production, that means a customer gets billed twice for one purchase.

Stripe provides a built-in fix: idempotency keys. When you send the same key with a request, Stripe returns the original result instead of creating a new one. Paired with Zod validation to reject bad input before it ever reaches Stripe, your API becomes safe to retry and impossible to abuse with garbage data.

In this step, get ready to:
  • Build a reusable validation middleware with Zod schemas that rejects invalid payment requests.
  • Add idempotency key support that prevents duplicate charges on retried requests.
  • Verify that repeated requests produce exactly one PaymentIntent and bad input gets a clean 400 error.
Build the validation middleware and Zod schemas

Right now your routes accept any JSON body without checking it. A request with a negative amount or a missing currency will pass straight through to Stripe and fail with an opaque error. You need a validation layer that catches bad input early and returns helpful error messages.

  • Open src/middleware/validate.js and replace its contents with the validation middleware:
function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        details: result.error.issues.map((issue) => ({
          field: issue.path.join('.'),
          message: issue.message,
        })),
      });
    }
    req.validated = result.data;
    next();
  };
}

module.exports = validate;

What does this code do?

The validate function takes a Zod schema and returns Express middleware. It calls schema.safeParse(req.body) to check the incoming request body against the schema's rules.

If parsing fails, it returns a 400 response with structured error details (field name + message for each issue). If parsing succeeds, it stores the validated data on req.validated so your route handler can trust the data is clean.

  • Save src/middleware/validate.js.
  • Now open src/routes/payments.js and replace the imports at the top of the file with these updated imports and schema definitions:
const express = require('express');
const { z } = require('zod');
const stripe = require('../lib/stripe');
const logger = require('../lib/logger');
const validate = require('../middleware/validate');

const router = express.Router();

// Define the shape of a valid payment creation request
const createPaymentSchema = z.object({
  amount: z.number().int().min(50, 'Amount must be at least 50 (cents)'),
  currency: z.string().length(3, 'Currency must be a 3-letter code').toLowerCase(),
  description: z.string().optional(),
});

// Define the shape of a valid payment confirmation request
const confirmPaymentSchema = z.object({
  paymentIntentId: z.string().startsWith('pi_', 'Must be a valid PaymentIntent ID'),
  paymentMethod: z.string().min(1, 'Payment method is required'),
});

What do these schemas enforce?

  • createPaymentSchema requires amount to be a positive integer of at least 50 (Stripe's minimum is $0.50 US, or 50 cents). The currency must be exactly 3 lowercase letters (like usd). The description field is optional.
  • confirmPaymentSchema requires paymentIntentId to start with pi_ (every real PaymentIntent ID does) and paymentMethod to be a non-empty string.
Wire idempotency and validation into the routes

With schemas defined, you need to replace the existing route handlers to use the validation middleware and add idempotency key support to the create route.

  • In src/routes/payments.js, replace the existing router.post('/', ...) handler with this updated version that adds validation and idempotency. First, the route setup through the Stripe create call:
router.post('/', validate(createPaymentSchema), async (req, res) => {
  // Use validated data instead of raw req.body
  const { amount, currency, description } = req.validated;
  // Read the idempotency key from the request header
  const idempotencyKey = req.headers['idempotency-key'];

  try {
    const options = {};
    if (idempotencyKey) {
      options.idempotencyKey = idempotencyKey;
    }

    const paymentIntent = await stripe.paymentIntents.create(
      {
        amount,
        currency,
        description,
        automatic_payment_methods: {
          enabled: true,
          allow_redirects: 'never',
        },
      },
      options
    );

What changed here?

  • validate(createPaymentSchema) is added as middleware. It runs before your handler and rejects invalid requests with a 400 automatically.
  • req.body is replaced with req.validated. This is the parsed, type-safe data from Zod.
  • The Idempotency-Key header is read and passed to stripe.paymentIntents.create() as a second options argument. Stripe uses this key to deduplicate requests.
  • Immediately below the create call, add the logging and response section to complete the try block, plus the catch block:
    logger.info('PaymentIntent created', {
      paymentIntentId: paymentIntent.id,
      amount,
      currency,
      idempotencyKey: idempotencyKey || 'none',
    });

    res.status(201).json({
      id: paymentIntent.id,
      status: paymentIntent.status,
      amount: paymentIntent.amount,
      currency: paymentIntent.currency,
      clientSecret: paymentIntent.client_secret,
    });
  } catch (error) {
    logger.error('Payment creation failed', {
      error: error.message,
      type: error.type,
      idempotencyKey: idempotencyKey || 'none',
    });

    if (error.type === 'StripeInvalidRequestError') {
      return res.status(400).json({ error: error.message });
    }
    res.status(500).json({ error: 'Payment creation failed' });
  }
});

Why log the idempotency key?

Both the success and error log entries now include idempotencyKey. When debugging in production, you can trace whether a request was a retry (same key) or a genuinely new request (different key or 'none').

  • Now replace the existing router.post('/confirm', ...) handler with this version that adds validation and logging. First the route setup through the success response:
router.post('/confirm', validate(confirmPaymentSchema), async (req, res) => {
  const { paymentIntentId, paymentMethod } = req.validated;

  try {
    const paymentIntent = await stripe.paymentIntents.confirm(paymentIntentId, {
      payment_method: paymentMethod,
    });

    logger.info('PaymentIntent confirmed', {
      paymentIntentId: paymentIntent.id,
      status: paymentIntent.status,
    });

    res.json({
      id: paymentIntent.id,
      status: paymentIntent.status,
      amount: paymentIntent.amount,
      currency: paymentIntent.currency,
    });
  • Add the catch block directly below to complete the confirm handler:
  } catch (error) {
    logger.error('Payment confirmation failed', {
      paymentIntentId,
      error: error.message,
      declineCode: error.decline_code || null,
    });

    if (error.type === 'StripeCardError') {
      return res.status(402).json({
        error: 'Payment declined',
        declineCode: error.decline_code,
        message: error.message,
      });
    }
    if (error.type === 'StripeInvalidRequestError') {
      return res.status(400).json({ error: error.message });
    }
    res.status(500).json({ error: 'Payment confirmation failed' });
  }
});

module.exports = router;
  • Save src/routes/payments.js.
  • Restart your server by pressing Ctrl+C in the terminal, then running:
node src/server.js

Seeing a require error on startup?

  • Check that the require('../middleware/validate') path in payments.js matches the actual file location src/middleware/validate.js.
  • Make sure validate.js exports the function with module.exports = validate (not an object wrapper).
  • help me debug this require error in my payments route
Test idempotency and validation

Time to prove both fixes work. First you will test that the Idempotency-Key header prevents duplicate charges. Then you will send invalid data to confirm Zod catches it.

  • Before you run the next command, predict: if you send the same request twice with the same Idempotency-Key header, will you get one PaymentIntent or two?
  • Send two identical payment requests with the same idempotency key by running this command twice:
curl -X POST http://localhost:4242/payments \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order_123" \
  -d '{"amount":2000,"currency":"usd"}'
  • Run it a second time (press the up arrow and hit Enter):
curl -X POST http://localhost:4242/payments \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order_123" \
  -d '{"amount":2000,"currency":"usd"}'

Both responses return the exact same PaymentIntent ID. Stripe recognized the duplicate key and returned the cached result instead of creating a new charge. Check your Stripe Dashboard to confirm only ONE new PaymentIntent exists for this test.

How does Stripe handle the key?

Stripe saves the result of the first request for any given idempotency key. Subsequent requests with the same key return the same result without re-executing the operation. Keys are automatically pruned after 24 hours.

  • Now test validation. Send a request with an invalid negative amount by running:
curl -X POST http://localhost:4242/payments \
  -H "Content-Type: application/json" \
  -d '{"amount":-5,"currency":"usd"}'

You should see a 400 response with a validation error message saying the amount must be at least 50 cents. Stripe was never called.

  • Try sending an empty body to test multiple validation errors at once:
curl -X POST http://localhost:4242/payments \
  -H "Content-Type: application/json" \
  -d '{}'

You should see a 400 response with details listing each missing required field (amount and currency). Your API now validates every request before it touches Stripe.

Requests still passing through without validation?

  • Confirm that validate(createPaymentSchema) appears as the second argument in your router.post('/', ...) call, between the path and the async (req, res) handler.
  • Check that the handler reads from req.validated (not req.body). If you still use req.body, validation runs but the route ignores the parsed result.
  • help me figure out why my Zod validation is not catching bad requests

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/middleware/validate.js and src/routes/payments.js.

ⓧ I'd like to double check the full code

Here is the complete src/middleware/validate.js:

function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        details: result.error.issues.map((issue) => ({
          field: issue.path.join('.'),
          message: issue.message,
        })),
      });
    }
    req.validated = result.data;
    next();
  };
}

module.exports = validate;

Here is the complete src/routes/payments.js. Confirm your file matches each section in order:

  • Imports (express, zod, stripe, logger, validate) and router setup.
  • createPaymentSchema and confirmPaymentSchema definitions.
  • POST / route with validate(createPaymentSchema), idempotency key logic, and updated logger.
  • POST /confirm route with validate(confirmPaymentSchema) and logging.
  • module.exports = router at the end.

Scroll up through the chunks above to verify each section matches your file. The imports and schemas block, then the POST / handler (two chunks), then the POST /confirm handler (two chunks ending with module.exports).

Your API now has two layers of protection. Zod catches garbage input before it ever reaches Stripe. Idempotency keys guarantee that network retries produce exactly one charge. Next up, you will close the loop by verifying webhook signatures so your server can trust the payment events Stripe sends back.

Verify Webhooks and Log Payment Events

Webhooks are Stripe's source of truth for payment outcomes.

Without signature verification, anyone could POST a fake "payment succeeded" event to your server and trick your system into fulfilling orders that were never paid for. In this step, you'll build a webhook endpoint that cryptographically verifies every incoming event, rejects forgeries, and logs legitimate payment lifecycle events with structured JSON output.

In this step, get ready to:
  • Build a webhook route that verifies Stripe signatures and rejects forged events.
  • Connect the Stripe CLI listener to forward events to your local server.
  • Run a full end-to-end payment flow and watch webhook events arrive in your structured logs.
Build the webhook signature verification route

Stripe signs every webhook payload with a secret unique to your endpoint. Your server must verify that signature before trusting the event data.

  • In Cursor, open src/routes/webhooks.js.
  • Replace the file contents with this signature verification route:
const express = require('express');
const stripe = require('../lib/stripe');
const logger = require('../lib/logger');
const config = require('../config');

const router = express.Router();

router.post('/', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['stripe-signature'];
  const endpointSecret = config.stripe.webhookSecret;

  if (!endpointSecret) {
    logger.warn('Webhook received but STRIPE_WEBHOOK_SECRET not configured');
    return res.status(500).json({ error: 'Webhook secret not configured' });
  }

  let event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err) {
    logger.error('Webhook signature verification failed', {
      error: err.message,
    });
    return res.status(400).json({ error: 'Webhook signature verification failed' });
  }

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

module.exports = router;

What does this code do?

  • express.raw({ type: 'application/json' }) keeps the request body as a raw Buffer instead of parsing it as JSON. Stripe needs the raw bytes to verify the signature.
  • The stripe-signature header contains Stripe's cryptographic signature for this payload.
  • stripe.webhooks.constructEvent() recalculates the expected signature using your webhook secret and compares it to the one in the header. If they don't match, an error is thrown and you return a 400.
  • If STRIPE_WEBHOOK_SECRET isn't configured, the route returns a 500 immediately so you know the problem is missing config, not a bad signature.

Why is the webhook route registered before express.json()?

If you look at src/server.js, the webhook router is mounted before app.use(express.json()). This is critical.

express.json() parses the body into a JavaScript object, which destroys the raw bytes. Signature verification requires the exact raw body Stripe sent. By registering the webhook route first, the raw body reaches your handler untouched.

  • Save src/routes/webhooks.js.
  • Restart your server by pressing Ctrl+C in the terminal, then running:
node src/server.js
  • Test that the route responds by running this in a second terminal:
curl -X POST http://localhost:4242/webhooks -H "Content-Type: application/json" -d '{"fake":"event"}'

You should see a 500 response with {"error":"Webhook secret not configured"}. That confirms the route is live and correctly blocking requests when no secret is set.

Seeing 'Cannot POST /webhooks'?

  • Make sure you saved src/routes/webhooks.js and restarted the server.
  • Check that src/server.js has app.use('/webhooks', webhooksRouter) before app.use(express.json()).
  • help me debug my /webhooks route returning Cannot POST
Add event handlers for payment lifecycle events

The verification gateway is working. Now you need to handle the specific events Stripe sends during a payment's lifecycle.

  • In src/routes/webhooks.js, find this line:
  res.json({ received: true });
  • Add the following switch statement directly above that line:
  switch (event.type) {
    case 'payment_intent.created': {
      const pi = event.data.object;
      logger.info('Webhook: PaymentIntent created', {
        event: event.type,
        paymentIntentId: pi.id,
        amount: pi.amount,
        currency: pi.currency,
      });
      break;
    }
    case 'payment_intent.succeeded': {
      const pi = event.data.object;
      logger.info('Webhook: Payment succeeded', {
        event: event.type,
        paymentIntentId: pi.id,
        amount: pi.amount,
        currency: pi.currency,
      });
      break;
    }
    case 'payment_intent.payment_failed': {
      const pi = event.data.object;
      logger.error('Webhook: Payment failed', {
        event: event.type,
        paymentIntentId: pi.id,
        lastError: pi.last_payment_error
          ? pi.last_payment_error.message
          : 'Unknown error',
        declineCode: pi.last_payment_error
          ? pi.last_payment_error.decline_code
          : null,
      });
      break;
    }
    default:
      logger.info('Webhook: Unhandled event type', { event: event.type });
  }

What do these event handlers do?

  • payment_intent.created fires when a new PaymentIntent is created. You log the ID, amount, and currency.
  • payment_intent.succeeded fires when payment completes successfully. In production, this is where you'd fulfill the order.
  • payment_intent.payment_failed fires on decline or error. You log the error message and decline code so you can debug failed payments.
  • The default case logs any other event type Stripe sends, so nothing arrives silently.
  • Save src/routes/webhooks.js.

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before continuing.

ⓧ I'd like to double check the full code

Your src/routes/webhooks.js should have these sections in order:

  • Four require statements (express, stripe, logger, config).
  • Router creation with express.Router().
  • A router.post('/', express.raw(...)) handler that reads the signature header, checks for the endpoint secret, and calls constructEvent() inside a try/catch.
  • A switch statement handling payment_intent.created, payment_intent.succeeded, payment_intent.payment_failed, and a default case.
  • res.json({ received: true }) at the end of the handler.
  • module.exports = router at the bottom.
Start the Stripe CLI listener and test webhook delivery

The Stripe CLI can forward real Stripe events to your local server. It gives you a temporary webhook signing secret for local development.

  • Open a new terminal tab (keep your server running in the first one).
  • Start the Stripe CLI listener by running:
stripe listen --forward-to localhost:4242/webhooks

You'll see output that includes a line like: Ready! Your webhook signing secret is whsec_....

  • Copy the whsec_ value from the CLI output.
  • Open your .env file and add this line (replacing the value with the one you just copied):
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
  • Switch back to the terminal running your server.
  • Press Ctrl+C to stop it, then restart it by running:
node src/server.js

Before you trigger an event, what do you think will happen when Stripe sends a payment_intent.succeeded event to your server?

  • Open a third terminal tab and trigger a test event by running:
stripe trigger payment_intent.succeeded

In your server terminal, you should see structured JSON log entries. Because triggering payment_intent.succeeded also triggers payment_intent.created as a side effect, you'll see both events logged.

What should I see?

Your server terminal should show two JSON log lines. One with "message":"Webhook: PaymentIntent created" and one with "message":"Webhook: Payment succeeded". Each includes the event type and a PaymentIntent ID starting with pi_.

Now test that forged events get rejected. What do you think happens when someone sends a fake payload without a valid Stripe signature?

  • In your third terminal, send a fake webhook event by running:
curl -X POST http://localhost:4242/webhooks -H "Content-Type: application/json" -d '{"type":"payment_intent.succeeded","data":{"object":{"id":"pi_fake"}}}'

You should see a 400 response: {"error":"Webhook signature verification failed"}. Your server rejected the forged event because it lacked a valid stripe-signature header.

Events not arriving or signature failing?

  • Make sure the stripe listen terminal is still running. If you restarted it, copy the new whsec_ value into .env and restart your server. The secret changes each time you restart the CLI listener.
  • Confirm STRIPE_WEBHOOK_SECRET in your .env has no extra spaces or quotes around the value.
  • help me debug webhook signature verification failures
Run the full end-to-end payment flow with webhooks

You've tested with CLI-triggered events. Now confirm that webhooks arrive for payments you create through your own API.

  • Create a PaymentIntent by running:
curl -X POST http://localhost:4242/payments -H "Content-Type: application/json" -d '{"amount":2000,"currency":"usd"}'
  • Copy the id value from the response (it starts with pi_).
  • Confirm the payment with a successful test card by running (replace the PaymentIntent ID with yours):
curl -X POST http://localhost:4242/payments/confirm -H "Content-Type: application/json" -d '{"paymentIntentId":"pi_your_id_here","paymentMethod":"pm_card_visa"}'

Check your server terminal. You should see Webhook: PaymentIntent created followed by Webhook: Payment succeeded with the amount and currency in each log line.

Now test a failed payment to see the decline event arrive.

  • Create another PaymentIntent by running:
curl -X POST http://localhost:4242/payments -H "Content-Type: application/json" -d '{"amount":3000,"currency":"usd"}'
  • Copy the new pi_ ID from the response.
  • Confirm with a decline test card by running (replace the PaymentIntent ID with yours):
curl -X POST http://localhost:4242/payments/confirm -H "Content-Type: application/json" -d '{"paymentIntentId":"pi_your_id_here","paymentMethod":"pm_card_visa_chargeDeclined"}'

Your server terminal should now show a Webhook: Payment failed log entry with the decline code in the structured JSON output. The full payment lifecycle is now observable through your webhook logs.

Why use webhooks when the API already returns the status?

The API response tells you the immediate result of your request. Webhooks tell you what happens asynchronously.

Some payments take minutes or days to settle (bank transfers, 3D Secure challenges). Webhooks are the only reliable way to know the final outcome. They're also essential for reconciliation, refund notifications, and dispute alerts.

Not seeing webhook logs for your API payments?

  • Confirm the stripe listen terminal shows events being forwarded (you'll see lines like [200] POST http://localhost:4242/webhooks).
  • If the CLI shows [500] responses, your webhook secret may have changed. Copy the current whsec_ value from the CLI output, update .env, and restart your server.
  • help me debug missing webhook events for API-created payments

Your payment API now has a complete security layer. Legitimate Stripe events pass verification and produce structured logs. Forged events get rejected with a 400 before they can trick your system.

You've built the three pillars of a production payment backend: validated input, idempotent writes, and verified webhooks. Next up, the Secret Mission challenges you to add rate limiting and a payment retrieval endpoint for even more production hardening.

Secret mission

Rate Limiting and Payment Retrieval

Your API accepts payments and verifies webhooks, but it has no defense against abuse. A single client can hammer your endpoints with unlimited requests. Add an in-memory rate limiter that returns 429 after 10 requests per minute, plus a GET endpoint for retrieving payment details by ID.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely in Stripe Test Mode and locally on your machine, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • stripe-payment-api project folder on your Desktop.
  • Stripe Test Mode account (free, no charges incurred).
  • Test PaymentIntents visible in your Stripe Dashboard.
  • Stripe CLI installed via Homebrew.

Keep everything running

No action needed. Choose this if you want to keep experimenting with your payment API or revisit the code later.

  • Your Stripe Test Mode account persists indefinitely at no cost. Test PaymentIntents are visible in your Dashboard under Test Mode.
  • Your local project folder stays on your Desktop ready to run again with node src/server.js.
  • The Stripe CLI remains installed and authenticated for future projects.

Pause - I'll come back to this later

Shut down any running processes to free up your terminal. Your files and account stay intact.

  • Press Ctrl+C in any terminal running your server or the Stripe CLI listener.
  • Everything else stays as-is. Your Stripe test account and local files are ready whenever you return.

Delete - I don't want to use this again

Remove all project resources and start fresh. Nothing here costs money, but this cleans up your machine and Stripe Dashboard.

  • Stop any running processes by pressing Ctrl+C in each terminal.
  • Delete the project folder by running:
rm -rf ~/Desktop/stripe-payment-api
  • On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\stripe-payment-api
  • Delete test data from the Stripe Dashboard. Open your Dashboard, navigate to the Workbench section, select the Overview tab, and find Testing Tools to review and delete your test data.
  • Uninstall the Stripe CLI by running:
brew uninstall stripe
  • Your Stripe Test Mode account itself is free and does not need to be deleted. It has no billing impact.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade payment API that processes charges through Stripe's Payment Intents flow, protected by idempotency keys and verified webhooks.

You've learned how to:

  • Build a REST API that creates and confirms Stripe PaymentIntents with proper error handling for declined cards, and proved the duplicate charge problem before fixing it with idempotency keys passed to the Stripe SDK.
  • Implement webhook signature verification that provably rejects forged events and logs legitimate payment lifecycle events with structured JSON logging.
  • Add Zod request validation that catches invalid input before it reaches Stripe, and use the Stripe CLI to test webhooks locally across the full payment lifecycle.
  • Secret Mission: Added a payment retrieval endpoint and rate limiting for production-grade API protection.

Ready to quiz yourself?