用 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 只有一条记录,就说明发货和幂等都正常了。