# 使用集成接入 Stripe 支付｜Creght

> 在 Creght 用 Stripe 集成收款：ctx.payment.stripe.checkoutSession 创建收银台，retrieveSession 复核回跳，verifyWebhook 验签 webhook，call 调用退款等 API，多个收款账号用渠道 tag 区分。

[![Creght](https://ugc.talizen.com/_assets/site/2061660904709165056/1780797461299__creght_logo.png)API for AI](/)

[查看 llms.txt](/llms.txt)

概览

- [Creght AI 编程指南](/api.md)

AI 可发现性

- [如何优化 llms.txt](/api/optimize-llms-txt.md)

站点配置

- [配置 talizen.config.ts](/api/talizen-config.md)
- [实现基于域名的多语言路由](/api/domain-locale-routing.md)

后端

- [在服务端调用外部 API 并管理缓存](/api/ssr-external-api-cache.md)
- [使用 Func 构建站点后端能力](/api/func-backend.md)
- [JSON 表：定义、读写与查询](/api/func-json-tables.md)
- [上传文件：直传与 Func 内生成](/api/func-assets-upload.md)
- [超时配置与流式响应](/api/func-timeout-streaming.md)
- [使用 Func 接入支付宝电脑网站支付](/api/func-alipay-payment.md)

集成

- [使用集成发送邮件与验证码](/api/func-email-integration.md)
- [使用集成接入支付宝支付](/api/func-alipay-integration.md)
- [使用集成接入 Stripe 支付](/api/func-stripe-integration.md)

登录与用户

- [注册时验证邮箱](/api/auth-verified-registration.md)
- [实现找回密码与修改密码](/api/auth-password-reset.md)
- [在 Func 里实现登录](/api/auth-func-login.md)
- [在 Func 里查询用户](/api/func-user-directory.md)

本页目录

- [payment 是命名空间，不是统一接口](#namespace)
- [连接 Stripe](#connect)
- [保存即校验做了什么](#verify)
- [订单表与事件表](#order-table)
- [发起支付](#create)
- [买家付完回跳](#confirm)
- [接收 webhook](#webhook)
- [退款与其余 API](#call)
- [一个项目挂两个 Stripe 账号](#channels)
- [平台不管的部分](#yours)
- [边界](#errors)

集成/使用集成接入 Stripe 支付

# 使用集成接入 Stripe 支付

连接 Stripe 集成后在 Func 里用 ctx.payment.stripe 创建 Checkout 收银台、复核回跳并验签 webhook：secret key 与 webhook 签名密钥留在服务端，订单、金额核对与幂等发货仍由站点代码负责。

复制 Markdown 链接

Stripe 是托管集成：在编辑器里连接一次，之后在 Func 里用 `ctx.payment.stripe` 创建 Checkout 收银台、复核回跳、验签 webhook。Secret key 与 webhook 签名密钥只保存在服务端，代码里不会出现任何密钥。

**本文范围**

Stripe **一次性支付** 的 Checkout（内联 price\_data）。订阅、用后台建好的 `priceId`、Stripe Connect 还没有专用方法；这些走 `call()`，密钥同样不进沙箱，见 [退款与其余 API](#call)。

## payment 是命名空间，不是统一接口

方法名按 Stripe 自己的产品形状取， **与 `ctx.payment.alipay.*` 一个方法都对不上**——这是预期结果而不是缺陷：支付宝是「签一个表单跳过去、等异步通知」，Stripe 是「建一个 Session 跳过去、回跳复核 + webhook」。硬抽一个 `createCheckout()` 只会变成一个更难懂的转发器。

分工是：平台负责 **密码学与凭据**，站点代码负责 **钱和货**。

| 平台保证 | 仍然由你写 |
| --- | --- |
| webhook 的 HMAC-SHA256 验签（对原始正文）与 5 分钟时间戳容差 | 金额来自服务端商品表 |
| 读取 `Stripe-Signature` 请求头 | 订单表与订单状态 |
| 事件与 Session 的 `livemode` 必须与集成一致 | 事件去重表 |
| Secret key 只在服务端持有，永不进沙箱 | 幂等发货 |
| `paid` 由 `status` \+ `paymentStatus` 一起算出 | 与本地订单核对金额与币种 |
| 保存凭据时真实校验一次 | 「这张单是不是当前用户的」 |

## 连接 Stripe

先在 Stripe 后台取参数：

1. 打开 [Developers → API keys](https://dashboard.stripe.com/apikeys)，复制 **Secret key**。测试用 `sk_test_` 开头的那把，正式用 `sk_live_`。 **不要** 复制 Publishable key。
2. （可选）在 Settings → Business 里记下账号 id，形如 `acct_xxx`。填了它，保存时会多一道「这把密钥确实属于这个账号」的校验。
3. Webhook 签名密钥 **先不用管**——它要等 Func 发布出去有了公开地址才能创建，见 [接收 webhook](#webhook)。

然后打开编辑器的 **后端 → 集成**，点 **Stripe**，按下表填写。保存时平台会真的调一次 Stripe，配置不对当场报错，而不是等到有人付款才发现。

| 字段 | 填什么 | 必填 |
| --- | --- | --- |
| Secret key | `sk_test_` 或 `sk_live_` 开头的那把密钥 | 是 |
| 正式模式 | 勾上表示这份配置收真钱。必须与密钥前缀一致，不一致直接报错 | 是 |
| 支付成功地址 | 付款后跳回的页面。可以带 `{CHECKOUT_SESSION_ID}` 占位符，Stripe 会替换成真实 session id | 建议 |
| 支付取消地址 | 买家在收银台点返回时跳到哪儿 | 建议 |
| 账号 id | `acct_` 开头，填了就多一道账号校验 | 否 |
| 默认币种 | 三位字母，如 `usd`。 `checkoutSession` 不传 `currency` 时用它 | 否 |
| Webhook 签名密钥 | `whsec_` 开头，接 webhook 时再回来填 | 接 webhook 才填 |

**「正式模式」这个勾为什么不能省**

密钥前缀已经决定了模式，再勾一次是为了给后面两处校验一个可信的基准：Session 和 webhook 事件上的 `livemode` 都要与它比对。Stripe 的测试模式和正式模式 **各有一套 webhook endpoint，而且可以配成同一个 URL**——配重了的话，测试模式点一次「发送测试事件」就会打进正式站点的发货逻辑。没有这个声明，就没有东西可比。

## 保存即校验做了什么

保存时平台调一次 `GET /v1/account`。这一次调用同时验三件事：密钥有效且没被吊销；密钥所属模式与「正式模式」勾选一致；账号 id 是你填的那个（填了才验）。

| 报错 | 该改哪一项 |
| --- | --- |
| `Invalid API Key provided` | 密钥贴错了，或在 Stripe 后台被 roll 掉了 |
| marked as live mode but the secret key is a test key | 勾了正式模式却用了 `sk_test_` 密钥 |
| marked as test mode but the secret key is a live key | 用了 `sk_live_` 密钥却没勾正式模式 |
| belongs to account `acct_…`, not `acct_…` | 密钥来自另一个 Stripe 账号 |
| expected it to start with `sk_test_` or `sk_live_` | 贴成了 Publishable key（ `pk_`）或 webhook secret（ `whsec_`） |

## 订单表与事件表

支付状态存在你自己的表里。创建 `/platform/table/payment_orders.json`：

```json
{
  "name": "Payment orders",
  "desc": "Stripe order state",
  "json_schema": {
    "type": "object",
    "properties": {
      "userId": { "type": "string" },
      "productId": { "type": "string" },
      "amount": { "type": "integer" },
      "currency": { "type": "string" },
      "orderNo": { "type": "string" },
      "status": { "type": "string", "enum": ["pending", "paid", "closed", "refunded"] },
      "stripeSessionId": { "type": "string" },
      "stripePaymentIntentId": { "type": "string" },
      "paidAt": { "type": "string" }
    },
    "required": ["userId", "productId", "amount", "currency", "orderNo", "status"]
  }
}
```

再建一张 `/platform/table/payment_events.json` 用于 webhook 去重：

```json
{
  "name": "Payment events",
  "desc": "Processed Stripe webhook events",
  "json_schema": {
    "type": "object",
    "properties": {
      "eventId": { "type": "string" },
      "orderNo": { "type": "string" },
      "type": { "type": "string" }
    },
    "required": ["eventId", "type"]
  }
}
```

## 发起支付

创建 `/backend/func/stripe.ts`：

```typescript
import type { TalizenFuncContext } from 'talizen/func-runtime'

// 金额由服务端商品表决定，浏览器只传 productId。
// 单位是最小货币单位的整数：500 表示 $5.00。
const PRODUCTS = {
  pro: { name: 'Pro plan', amount: 500, currency: 'usd' },
} as const

export function create(input: { productId?: string }, ctx: TalizenFuncContext) {
  const user = ctx.auth.requireUser()
  const productId = String(input.productId || '') as keyof typeof PRODUCTS
  const product = PRODUCTS[productId]
  if (!product) throw new Error('invalid product')

  // 订单号自己生成并先落库：它同时是订单表的键、client_reference_id 和幂等键。
  const orderNo = 'C' + crypto.randomUUID().replace(/-/g, '')
  ctx.db.insert('payment_orders', {
    userId: String(user.id),
    productId,
    amount: product.amount,
    currency: product.currency,
    orderNo,
    status: 'pending',
  })

  const session = ctx.payment.stripe.checkoutSession({
    clientReferenceId: orderNo,
    amount: product.amount,
    currency: product.currency,
    name: product.name,
    customerEmail: user.email,
  })
  ctx.db.update('payment_orders', ctx.db.query('payment_orders', {
    where: { orderNo }, limit: 1,
  }).list[0].id, { stripeSessionId: session.id })

  return { orderNo, payUrl: session.url }
}
```

浏览器拿到 `payUrl` 直接跳转：

```typescript
import { invoke } from 'talizen/func'

const { payUrl } = await invoke<{ payUrl: string }>('stripe.create', {
  productId: 'pro',
})
window.location.assign(payUrl)
```

| 参数 | 说明 |
| --- | --- |
| `clientReferenceId` | 必填，你的订单号，200 字符以内的字母、数字、 `-`、 `_`。 **平台不代生成**：它同时是订单表的键、Stripe 的 `client_reference_id`、 `metadata.client_reference_id` 和幂等键的默认值 |
| `amount` | 必填， **最小货币单位的整数**： `500` 表示 $5.00。传 `'9.90'` 这种小数字符串会直接报错，不会被当成 9 分 |
| `currency` | 三位币种代码，留空取集成里配的默认值 |
| `name` | 必填，收银台上显示的商品名 |
| `description` / `quantity` | 可选，商品描述与数量（默认 1） |
| `successUrl` / `cancelUrl` | 可选，覆盖集成里配的回跳地址 |
| `customerEmail` | 可选，预填收银台上的邮箱 |
| `metadata` | 可选，附加元数据，会回到 Session 与 webhook 事件上 |
| `expiresAt` | 可选，unix 秒或 Date。Stripe 只接受 30 分钟到 24 小时之间；留空用 Stripe 默认的 24 小时 |
| `idempotencyKey` | 可选，默认由订单号推导。同一个订单号重复建单只会拿到同一张 Session |

返回 `{ id, url, clientReferenceId, amountTotal, currency, expiresAt, livemode }`。

## 买家付完回跳

买家付完会跳到 `successUrl`。把地址里的 `session_id` 传回 Func 复核：

```typescript
export function confirm(input: { sessionId?: string }, ctx: TalizenFuncContext) {
  const user = ctx.auth.requireUser()
  const s = ctx.payment.stripe.retrieveSession(String(input.sessionId || ''))
  if (!s.paid) return { paid: false }

  const { list } = ctx.db.query('payment_orders', {
    where: { orderNo: s.clientReferenceId },
    limit: 1,
  })
  const order = list[0]
  // 这三条平台替不了：单子存不存在、是不是这个用户的、金额币种对不对。
  if (!order || order.userId !== String(user.id)) throw new Error('order not found')
  if (order.amount !== s.amountTotal || order.currency !== s.currency) {
    throw new Error('amount mismatch')
  }
  if (order.status === 'paid') return { paid: true } // 已经处理过

  ctx.db.update('payment_orders', order.id, {
    status: 'paid',
    stripePaymentIntentId: s.paymentIntentId,
    paidAt: new Date().toISOString(),
  })
  // 发放权益同样要以 orderNo 做幂等保护。
  return { paid: true }
}
```

```typescript
// 回跳地址形如 https://example.com/pay/done?session_id=cs_live_xxx
const sessionId = new URLSearchParams(location.search).get('session_id')
if (sessionId) {
  const { paid } = await invoke<{ paid: boolean }>('stripe.confirm', { sessionId })
}
```

**回跳参数能不能信**

`session_id` 在地址栏里，任何人都能改，所以 **它本身不能信**。但拿它去 `retrieveSession` 查回来的 Session 只可能属于你这把密钥的账号——查不到就报错，查到了就是 Stripe 的原始事实。也就是说「查回来」这一步就是鉴权。

这是 Stripe 比支付宝多出来的一条路：不必等 webhook 就能发货。但 **「这张单是不是当前用户的」平台证明不了**， `clientReferenceId` 必须和登录用户的订单对上。

| 返回字段 | 说明 |
| --- | --- |
| `paid` | `status === 'complete'` 且 `paymentStatus === 'paid'`，由平台算好。 **别自己只判其中一半**——只看 `paymentStatus` 会把还没完成的 Session 算成已付 |
| `clientReferenceId` | 你建单时传的订单号 |
| `amountTotal` / `currency` | 实付金额与币种，必须与自己订单表里的核对 |
| `paymentIntentId` | 存下来：退款事件是按 payment intent 匹配的 |
| `customerEmail` | 买家在收银台填的邮箱，没填则是你预填的那个 |
| `status` / `paymentStatus` | 原始状态： `open` / `complete` / `expired`， `paid` / `unpaid` / `no_payment_required` |
| `metadata` / `session` | 元数据与完整 Stripe 对象，读上面没列出的字段用它 |

## 接收 webhook

回跳复核只覆盖「买家老老实实跳回来了」这一种情况。买家关掉页面、用异步支付方式、或者事后退款，都只能靠 webhook。

1. 先把上面的 Func 发布出去，拿到公开地址，例如 `https://example.com/func/stripe.webhook`。
2. 在 Stripe 后台 [Developers → Webhooks](https://dashboard.stripe.com/webhooks) 新建 endpoint 指向它，勾上 `checkout.session.completed` 等需要的事件。 **注意测试模式和正式模式要各建一个。**
3. 复制这个 endpoint 的 `whsec_...` 签名密钥，回到 **后端 → 集成** 填进 Stripe 集成。

```typescript
export async function webhook(_input: unknown, ctx: TalizenFuncContext) {
  // 验签失败会抛错，伪造的事件走不到下面任何一行。
  // 必须传原文：用 input 或 JSON.stringify 重新序列化过的正文验不过签。
  const event = ctx.payment.stripe.verifyWebhook(await ctx.request.text())

  // 事件会重投、会乱序，去重表是你自己的。
  const seen = ctx.db.query('payment_events', { where: { eventId: event.id }, limit: 1 })
  if (seen.list.length > 0) return new Response('ok')

  if (event.type === 'checkout.session.completed') {
    const session = event.object as any
    if (session.payment_status === 'paid') {
      const { list } = ctx.db.query('payment_orders', {
        where: { orderNo: session.client_reference_id },
        limit: 1,
      })
      const order = list[0]
      if (!order) return new Response('order not found', { status: 400 })
      if (order.amount !== session.amount_total) {
        return new Response('amount mismatch', { status: 400 })
      }
      if (order.status !== 'paid') {
        ctx.db.update('payment_orders', order.id, {
          status: 'paid',
          stripePaymentIntentId: String(session.payment_intent || ''),
          paidAt: new Date().toISOString(),
        })
      }
      ctx.db.insert('payment_events', {
        eventId: event.id,
        orderNo: order.orderNo,
        type: event.type,
      })
    }
  }

  return new Response('ok')
}
```

**两个最容易写错的地方，平台都替你挡住了**

**一、必须用原始正文。** `verifyWebhook` 收的是 `await ctx.request.text()`。Stripe 的签名覆盖的是原始字节，把 JSON 解析再序列化回去（键顺序、空格、转义都可能变）必然验不过——用 `input` 或 `JSON.stringify()` 就是这种情况。

**二、签名头不用你传。** `Stripe-Signature` 由平台从请求上下文里读。让每个站点自己去 `ctx.request` 上找那个头的后果是：取不到就是空串，表现为「验签永远失败」，而且本地完全看不出来。

平台在这一步完成：HMAC-SHA256 验签、5 分钟时间戳容差（挡重放）、签名头读取、事件 `livemode` 与集成一致。 **任何一步不通过都直接抛错**，而不是返回一个可能被当成 falsy 忽略的值。返回非 2xx 时 Stripe 会重投。

| 返回字段 | 说明 |
| --- | --- |
| `id` | 事件 id， **用它做去重表的键**——Stripe 明确说明事件可能重复、可能乱序 |
| `type` | 如 `checkout.session.completed`、 `charge.refunded` |
| `object` | `event.data.object`，绝大多数场景要读的就是它 |
| `event` | 完整事件，读 `data.previous_attributes` 之类字段用它 |
| `livemode` / `created` / `apiVersion` | 模式、时间与事件的 API 版本 |

## 退款与其余 API

退款、订阅、查 charge 等等用 `call`，平台代持密钥：

```typescript
// 全额退款
const refund = ctx.payment.stripe.call('POST', '/v1/refunds', {
  payment_intent: order.stripePaymentIntentId,
})

// 带嵌套参数：{ a: { b: [1] } } 会展开成 a[b][0]=1
const session = ctx.payment.stripe.call('POST', '/v1/checkout/sessions', {
  mode: 'subscription',
  line_items: [{ price: 'price_xxx', quantity: 1 }],
  success_url: 'https://example.com/pay/done',
  cancel_url: 'https://example.com/pay/cancel',
})
```

签名是 `call(method, path, params?, idempotencyKey?)`。 `method` 支持 `GET` / `POST` / `DELETE`， `path` 必须以 `/v1/` 开头。参数按 Stripe 的表单规则展开，嵌套直接写对象和数组即可。HTTP 错误会按 Stripe 的错误类型降级成 400（你的配置或参数不对）或 502（Stripe 侧的问题）。

## 一个项目挂两个 Stripe 账号

给两份集成配不同的 tag，用 `via()` 选：

```typescript
ctx.payment.stripe.checkoutSession({ ... })              // 默认渠道，等价于 via('default')
ctx.payment.stripe.via('eu').checkoutSession({ ... })    // 另一个收款账号
ctx.payment.stripe.via('eu').verifyWebhook(raw)          // webhook 要用同一个渠道
```

**与 ctx.email 有一处刻意的不同**

同一个 tag 命中多份支付集成会 **直接报错**，不会像发信那样随机挑一份。Stripe 这边理由更硬：webhook 签名密钥是 **按 endpoint 发的**，A 账号的事件拿 B 账号的密钥验必然失败，表现为「用户付了钱，站点永远收不到事件」。

所以两个收款账号就是两个 tag，并且建单和处理 webhook 要用 **同一个** tag。

## 平台不管的部分

这几条是支付里最容易出事的地方，平台帮不了你：

- **金额由服务端商品表决定**，浏览器只传 `productId`。让浏览器传金额等于让人自己定价。
- **订单归属要自己核。** `retrieveSession` 能证明这张单属于你的 Stripe 账号，不能证明它属于当前登录用户。
- **金额与币种要和本地订单核对**，两个都要比。
- **webhook 事件要去重**，以 `event.id` 为键——事件会重投、会乱序。
- **发放权益要幂等**，以订单号为键：回跳复核和 webhook 可能同时把同一笔算成已付。

## 边界

- 只支持一次性支付的 Checkout（内联 `price_data`）。订阅、后台建好的 `priceId`、Stripe Connect 还没有专用方法，用 `call()`。
- 金额是 **最小货币单位的整数**，平台不做元/分换算。 `ctx.payment.alipay` 用的是「元」字符串，两边不一致是故意的——各自跟自己的上游对齐，比强行统一更不容易错。
- 支付集成 **不能** 打开「把密钥暴露给 Func 代码」：收款密钥不进沙箱，由平台代调。想完全自己接就走另一条路——在 **后端 → 环境变量** 里自己配 `STRIPE_*`，用 `fetch` \+ `crypto.subtle` 写自己的 Func，两条路互不干扰。
- 回跳地址与 webhook 地址都必须是 **https**，webhook 还必须是 **已发布** 的地址：预览域名下的 Func 收不到线上事件。
- 测试模式与正式模式用各自的密钥和各自的 webhook endpoint。 **上线前必须在正式模式真实收到过一笔事件**，这一步没有替代品。

**相关文档**

Func 基础能力（文件、调用、数据表、鉴权、超时、SSE）见 [Func 后端完整指南](/api/func-backend.md)；收人民币见 [使用集成接入支付宝支付](/api/func-alipay-integration.md)；邮件与验证码见 [使用集成发送邮件与验证码](/api/func-email-integration.md)。

![Creght](https://ugc.talizen.com/_assets/site/2061660904709165056/1780797461299__creght_logo.png)

此网站使用 [Creght](/) 创建

![微信客服](https://fsu.creght.com/site/2066727200882692096/1785119134612__image.png?w=3072&fmt=webp)

微信客服

## 链接

- [SaaS 官网生成器](/saas-website-builder.md)
- [价格](/price.md)
- [解决方案](/solution.md)
- [客户案例](/customers.md)
- [AI 模型对比](/ai-models.md)
- [帮助中心](/help.md)
- [联系我们](/contact.md)
- [更新记录 & 博客](/blogs.md)
- [退款说明](/tuikuan.md)

## 资源

- [全部资源](/resources.md)
- [模板](/templates.md)
- [组件库](https://creghtlib.site.creght.com)
- [动效库](/design/effects.md)
- [Figma to Creght](/figma2creght.md)
- [API](/api.md)

## 产品对比

- [对比上线了](/creght-vs-sxl.md)
- [对比凡科建站](/creght-vs-fkw.md)
- [Creght vs 自己写代码](/compare/self-coding.md)
- [Creght vs 外包](/compare/outsourcing.md)
- [Creght vs Framer](/compare/framer.md)
- [Creght vs WordPress](/compare/wordpress.md)

## 协议

- [用户协议](/legal/terms.md)
- [隐私政策](/legal/privacy.md)
- [可接受使用政策](/legal/acceptable-use.md)

## 社交媒体

- [小红书](https://www.xiaohongshu.com/user/profile/5a38606811be10715f4895b6)
- [哔哩哔哩](https://space.bilibili.com/513308095)

[蜀ICP备2023038192号-2](https://beian.miit.gov.cn)

免费方案评估

> 全站页面清单：[/llms.txt](/llms.txt)
