开发技巧

Stripe Checkout 实战:PDF 数字商品收款与自动交付

用 Stripe Checkout 卖一本 PDF 电子书:用户点“购买”跳转到 Stripe 托管的支付页,付款后 webhook 自动“发货”生成专属下载链接,成功页也能立即拿到链接,同一订单无论通知多少次都只发一次货。

BINBHO · 图文教程

用 Stripe Checkout 卖一本 PDF 电子书:用户点“购买”跳转到 Stripe 托管的支付页,付款后 webhook 自动“发货”生成专属下载链接,成功页也能立即拿到链接,同一订单无论通知多少次都只发一次货。

先看清成品

成品是一个 Node.js + Express 小网站:首页一个购买按钮,/webhook 接收 Stripe 通知,/success 显示下载链接,/download/令牌 下载文件。本文代码已实际运行 5 个自动测试全部通过:验签失败返回 400,重复投递的事件只生成一个订单,下载令牌有效、无效令牌返回 404,创建支付页返回 303 跳转,未付款订单不发货。本文没有真实 Stripe 账号,测试用两种方式代替:一是 SDK 自带的 generateTestHeaderString 生成合法签名的测试事件;二是 Stripe 官方的 API 模拟服务 stripe-mock(Docker 运行),它会校验请求参数并返回固定样例数据。用真实测试卡在 Stripe 页面上付款的过程没有在本文环境中操作。

注意:Stripe 账号必须用你自己公司或个人的真实资料,在 Stripe 支持的国家或地区注册,不要借用或租用他人账号收款。销售前确认你所在地区对线上销售、发票和税务的要求。

做完之后你会得到什么?

成果 说明
商品与价格 在 Stripe 后台创建,代码里只引用价格 ID
托管支付页 Stripe Checkout,支持银行卡和多种支付方式
自动发货 webhook 收到付款事件后生成下载令牌
幂等处理 同一个订单重复通知只处理一次
自动化测试 不连真实账号也能测

需要准备哪些环境?

项目 要求 说明
Stripe 账号 在 Stripe 支持的国家或地区注册 全程使用测试模式,不产生真实扣款
Node.js 20.6 或更高 用到 --env-file 参数
Stripe CLI 最新版 本地接收 webhook、触发测试事件
stripe(Node SDK) 23.x 本文使用 23.0.0

第一步:怎样在 Stripe 里准备商品?

1. 切换到测试模式

登录 Stripe 后台,确认右上角处于测试环境(Sandbox 或测试模式)。测试模式下的密钥以 sk_test_ 开头。

2. 创建商品

进入 商品目录 → 添加产品:

字段 填写
名称 一周学会家常菜(电子书)
价格 一次性,填写你的售价和币种

保存后复制价格 ID(price_ 开头)。

3. 获取密钥

在 开发者 → API 密钥 复制测试私钥。

注意:私钥只能放在服务器端的 .env 文件里,不能写进前端代码或提交到 Git。

第二步:项目结构是什么样的?

ebook-shop/
├── public/
│   └── index.html      # 商品页
├── files/
│   └── ebook.pdf       # 要卖的文件,不放在 public 里
├── app.js              # 路由和发货逻辑
├── orders.js           # 订单存储(演示用 JSON 文件)
├── server.js           # 启动入口
├── app.test.js         # 自动测试
├── package.json
└── .env                # 密钥,不提交

1. 让 AI 帮你写初稿(可选)

用 Node.js + Express 写一个卖 PDF 电子书的 Stripe Checkout 集成:
- POST /create-checkout-session 创建 mode=payment 的 Checkout Session 并 303 跳转
- POST /webhook 用原始请求体验证签名,处理 checkout.session.completed 和 checkout.session.async_payment_succeeded
- 发货函数要幂等:同一个 session 只发一次货
- /success 页面也调用同一个发货函数,显示下载链接
- 下载链接用随机令牌,文件不放在静态目录

第三步:核心代码怎么写?

1. package.json

{
  "name": "ebook-shop",
  "private": true,
  "type": "commonjs",
  "scripts": {
    "start": "node --env-file=.env server.js",
    "test": "node --test"
  },
  "dependencies": {
    "express": "^5.3.0",
    "stripe": "^23.0.0"
  }
}

2. .env.example

STRIPE_SECRET_KEY=sk_test_替换成你的测试密钥
STRIPE_WEBHOOK_SECRET=whsec_替换成stripe_listen输出的密钥
PRICE_ID=price_替换成电子书价格ID
BASE_URL=http://localhost:4242

3. orders.js

// 极简订单存储:JSON 文件。正式项目请换成数据库,并给 session_id 加唯一索引
const fs = require("node:fs");
const crypto = require("node:crypto");

class Orders {
  constructor(file) {
    this.file = file;
    this.data = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
  }
  get(sessionId) {
    return this.data[sessionId];
  }
  // 幂等:同一个 Checkout Session 只发一次货,返回已有或新建的订单
  fulfillOnce(sessionId, info) {
    if (this.data[sessionId]) return { order: this.data[sessionId], created: false };
    const order = { ...info, token: crypto.randomBytes(16).toString("hex"), fulfilledAt: new Date().toISOString() };
    this.data[sessionId] = order;
    fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2));
    return { order, created: true };
  }
  byToken(token) {
    return Object.values(this.data).find((o) => o.token === token);
  }
}
module.exports = { Orders };

4. app.js

const express = require("express");
const path = require("node:path");

function createApp({ stripe, orders, webhookSecret, priceId, baseUrl, filePath }) {
  const app = express();

  // 发货函数:webhook 和成功页都会调用,必须可以重复调用
  async function fulfillCheckout(sessionId) {
    const existing = orders.get(sessionId);
    if (existing) return existing;
    const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["line_items"] });
    if (session.payment_status === "unpaid") return null;
    const { order, created } = orders.fulfillOnce(sessionId, {
      email: session.customer_details?.email ?? null,
      amount: session.amount_total,
      currency: session.currency,
      items: session.line_items?.data.map((li) => li.description) ?? [],
    });
    if (created) console.log(`已发货 ${sessionId} -> ${order.email}`);
    return order;
  }

  // webhook 必须拿到原始请求体才能验签,所以放在 express.json() 之前,用 express.raw
  app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], webhookSecret);
    } catch (err) {
      console.warn(`签名验证失败:${err.message}`);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }
    if (["checkout.session.completed", "checkout.session.async_payment_succeeded"].includes(event.type)) {
      await fulfillCheckout(event.data.object.id);
    }
    res.json({ received: true });
  });

  app.use(express.static(path.join(__dirname, "public")));

  app.post("/create-checkout-session", async (_req, res) => {
    const session = await stripe.checkout.sessions.create({
      mode: "payment",
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${baseUrl}/`,
    });
    res.redirect(303, session.url);
  });

  app.get("/success", async (req, res) => {
    const order = req.query.session_id && (await fulfillCheckout(String(req.query.session_id)));
    if (!order) return res.status(402).send("还没有收到付款,请稍后刷新。");
    res.send(`<meta charset="utf-8"><h1>购买成功</h1><p>下载链接:<a href="/download/${order.token}">点击下载电子书</a></p>`);
  });

  app.get("/download/:token", (req, res) => {
    if (!orders.byToken(req.params.token)) return res.status(404).send("链接无效");
    res.download(filePath, "ebook.pdf");
  });

  return app;
}
module.exports = { createApp };
关键点 原因
/webhook 用 express.raw 验签需要原始字节,先被解析成 JSON 就会验签失败
同时处理 async_payment_succeeded 部分支付方式不是即时到账,付款成功的通知会晚一些
检查 payment_status 未付款的 Session 不发货
成功页也调用发货函数 webhook 可能延迟,用户在页面上就能立即拿到链接
fulfillOnce Stripe 可能重复投递同一事件,必须幂等

官方文档特别强调:不能只靠成功页发货,因为用户付款后可能断网,根本没回到你的网站;webhook 才是可靠的发货依据。

5. server.js 和 public/index.html

const Stripe = require("stripe");
const path = require("node:path");
const { createApp } = require("./app");
const { Orders } = require("./orders");

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = createApp({
  stripe,
  orders: new Orders(path.join(__dirname, "orders.json")),
  webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
  priceId: process.env.PRICE_ID,
  baseUrl: process.env.BASE_URL ?? "http://localhost:4242",
  filePath: path.join(__dirname, "files", "ebook.pdf"),
});
app.listen(4242, () => console.log("运行在 http://localhost:4242"));
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>《一周学会家常菜》电子书</title>
<body style="font-family:system-ui;max-width:560px;margin:60px auto;padding:0 16px">
  <h1>《一周学会家常菜》</h1>
  <p>PDF 电子书,付款后立即下载。</p>
  <form action="/create-checkout-session" method="POST">
    <button type="submit" style="font-size:18px;padding:10px 24px">购买</button>
  </form>
</body>
</html>

第四步:怎样在本地测试完整流程?

1. 安装并启动

npm install
cp .env.example .env   # 填入密钥和价格 ID
mkdir -p files && cp 你的电子书.pdf files/ebook.pdf
npm start

2. 转发 webhook

另开一个终端:

stripe login
stripe listen --forward-to localhost:4242/webhook

把输出里的 whsec_... 填进 .env 的 STRIPE_WEBHOOK_SECRET,重启服务。

3. 用测试卡付款

浏览器打开 http://localhost:4242,点“购买”,在 Stripe 页面填写:

字段 测试值
卡号 4242 4242 4242 4242
有效期 任意未来日期
CVC 任意 3 位数

4. 检查结果

位置 期望
stripe listen 终端 出现 checkout.session.completed 并返回 200
服务日志 已发货 cs_test_... -> 邮箱
浏览器 跳转到成功页,显示下载链接
orders.json 只有一条该订单记录

第五步:怎样写自动测试?

1. app.test.js

// 用官方 stripe-mock 和 SDK 自带的测试签名函数验证,不需要真实账号
const { test, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const Stripe = require("stripe");
const { createApp } = require("./app");
const { Orders } = require("./orders");

const SECRET = "whsec_test_secret";
const useMock = !!process.env.STRIPE_MOCK;
const stripe = new Stripe("sk_test_123", useMock ? { host: "localhost", port: 12111, protocol: "http" } : {});
// 发货测试用假的 retrieve 返回“已付款”;stripe-mock 的固定数据是 unpaid,单独测试
const realRetrieve = stripe.checkout.sessions.retrieve.bind(stripe.checkout.sessions);
stripe.checkout.sessions.retrieve = async (id) => id.startsWith("cs_mock_") ? realRetrieve(id) : ({
    id, payment_status: "paid", amount_total: 2900, currency: "cny",
    customer_details: { email: "reader@example.com" }, line_items: { data: [{ description: "电子书" }] },
});

const dir = fs.mkdtempSync(path.join(os.tmpdir(), "shop-"));
fs.writeFileSync(path.join(dir, "ebook.pdf"), "%PDF-1.4 test");
const orders = new Orders(path.join(dir, "orders.json"));
let server, base;

before(async () => {
  const app = createApp({ stripe, orders, webhookSecret: SECRET, priceId: "price_123", baseUrl: "http://x", filePath: path.join(dir, "ebook.pdf") });
  server = app.listen(0);
  base = `http://localhost:${server.address().port}`;
});
after(() => server.close());

function signed(payload) {
  const body = JSON.stringify(payload);
  const header = stripe.webhooks.generateTestHeaderString({ payload: body, secret: SECRET });
  return { body, header };
}

test("签名错误返回 400", async () => {
  const r = await fetch(`${base}/webhook`, { method: "POST", headers: { "content-type": "application/json", "stripe-signature": "t=1,v1=bad" }, body: "{}" });
  assert.equal(r.status, 400);
});

test("checkout.session.completed 触发发货,重复投递只发一次", async () => {
  const { body, header } = signed({ id: "evt_1", type: "checkout.session.completed", data: { object: { id: "cs_test_1" } } });
  for (let i = 0; i < 2; i++) {
    const r = await fetch(`${base}/webhook`, { method: "POST", headers: { "content-type": "application/json", "stripe-signature": header }, body });
    assert.equal(r.status, 200);
  }
  assert.equal(Object.keys(orders.data).length, 1);
  assert.ok(orders.get("cs_test_1").token);
});

test("成功页拿到下载链接,下载链接有效", async () => {
  const html = await (await fetch(`${base}/success?session_id=cs_test_1`)).text();
  const token = html.match(/download\/([a-f0-9]+)/)[1];
  const r = await fetch(`${base}/download/${token}`);
  assert.equal(r.status, 200);
  assert.equal((await fetch(`${base}/download/nope`)).status, 404);
});

test("创建 Checkout Session 后 303 跳转", { skip: !useMock && "需要 stripe-mock" }, async () => {
  const r = await fetch(`${base}/create-checkout-session`, { method: "POST", redirect: "manual" });
  assert.equal(r.status, 303);
  assert.match(r.headers.get("location"), /^https:\/\//);
});

test("未付款的 Session 不发货", { skip: !useMock && "需要 stripe-mock" }, async () => {
  const r = await fetch(`${base}/success?session_id=cs_mock_unpaid`);
  assert.equal(r.status, 402);
  assert.equal(orders.get("cs_mock_unpaid"), undefined);
});

2. 运行

不启动 stripe-mock 时,只跑不需要网络的 3 个测试:

npm test

启动 stripe-mock 后跑全部测试:

docker run -d --rm -p 12111:12111 stripe/stripe-mock:latest
STRIPE_MOCK=1 npm test

本文实际输出:

ok 1 - 签名错误返回 400
ok 2 - checkout.session.completed 触发发货,重复投递只发一次
ok 3 - 成功页拿到下载链接,下载链接有效
ok 4 - 创建 Checkout Session 后 303 跳转
ok 5 - 未付款的 Session 不发货
# pass 5
# fail 0

第六步:上线前要改什么?

项目 做法
订单存储 换成数据库,给 session ID 加唯一约束
Webhook 端点 在后台 开发者 → Webhooks 添加 https://你的域名/webhook,使用该端点自己的 whsec_
密钥 换成正式密钥,放在服务器环境变量中
下载链接 加过期时间或下载次数限制
HTTPS 正式环境必须使用 HTTPS

Webhook 投递失败时 Stripe 会自动重试,正式模式下最长重试 3 天,所以接口应尽快返回 200,耗时操作放到后台处理。

出问题时该怎么调?

1. 签名验证失败

签名验证失败:No signatures found matching the expected signature for payload
原因 解决方法
请求体被 JSON 解析过 /webhook 路由必须在 express.json() 之前,并使用 express.raw
密钥不对 本地用 stripe listen 输出的密钥,线上用端点详情页的密钥
重启 stripe listen 后密钥变了 重新复制

2. 付款成功但没发货

在 stripe listen 终端看事件是否转发、返回码是多少。返回 500 说明发货函数报错,查看服务日志。

也可以在后台 开发者 → 事件 里找到该事件,点“重新发送”。

3. 用触发器快速测试

stripe trigger checkout.session.completed

触发器创建的是 CLI 自动生成的测试数据,能验证 webhook 链路是否通畅。

常见问题怎么处理?

现象 原因 解决方法
No such price 价格 ID 来自另一个模式 测试密钥只能用测试模式的价格
点购买后 500 .env 没加载 用 npm start(带 --env-file)启动
成功页显示未付款 延迟到账的支付方式 等待 async_payment_succeeded 后刷新
文件能被直接访问 文件放进了 public 移到 files/,只通过令牌下载
重复发货 没做幂等 按 session ID 去重
测试时 fetch 报错 Node 版本太低 升级到 20 或更高

项目资源

用途 地址
Checkout 快速开始 https://docs.stripe.com/checkout/quickstart
订单履行(发货) https://docs.stripe.com/checkout/fulfillment
Webhook 文档 https://docs.stripe.com/webhooks
Stripe CLI https://docs.stripe.com/stripe-cli
测试卡号 https://docs.stripe.com/testing
stripe-mock https://github.com/stripe/stripe-mock
Node SDK https://github.com/stripe/stripe-node
支持的国家和地区 https://stripe.com/global

建议先跑 npm test,3 个本地测试通过后,再用测试密钥和 stripe listen 走一遍真实的测试卡付款。

看到成功页的下载链接、并且 orders.json 只有一条记录,就说明发货和幂等都正常了。