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

使用集成接入 Stripe 支付

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

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

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,复制 Secret key。测试用 sk_test_ 开头的那把,正式用 sk_live_。不要复制 Publishable key。
  2. (可选)在 Settings → Business 里记下账号 id,形如 acct_xxx。填了它,保存时会多一道「这把密钥确实属于这个账号」的校验。
  3. Webhook 签名密钥先不用管,它要等 Func 发布出去有了公开地址才能创建,见接收 webhook。

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

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

保存即校验做了什么

保存时平台调一次 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:

{
  "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 去重:

{
  "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:

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 直接跳转:

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 复核:

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 }
}
// 回跳地址形如 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 })
}
返回字段说明
paidstatus === '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 新建 endpoint 指向它,勾上 checkout.session.completed 等需要的事件。注意测试模式和正式模式要各建一个。
  3. 复制这个 endpoint 的 whsec_... 签名密钥,回到后端 → 集成填进 Stripe 集成。
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')
}

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

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

订阅

订阅走 subscriptionSession,而不是给 checkoutSession 加一个 mode 参数。因为 mode 一变,必填参数、回调事件、后续对账全都要跟着变,那不是一个参数,是另一个方法。

export function subscribe(input: { plan?: string }, ctx: TalizenFuncContext) {
  const user = ctx.auth.requireUser()

  // 订阅号自己生成并先落库,它是续费时唯一能把发票对回这个用户的东西。
  const subNo = 'S' + crypto.randomUUID().replace(/-/g, '')
  ctx.db.insert('subscriptions', {
    userId: String(user.id),
    subNo,
    status: 'pending',
  })

  const session = ctx.payment.stripe.subscriptionSession({
    clientReferenceId: subNo,
    priceId: 'price_xxx',        // 或内联:amount + currency + interval + name
    customerEmail: user.email,
    trialDays: 14,               // 可选
    allowPromotionCodes: true,   // 可选
  })
  return { subNo, payUrl: session.url }
}

价格有两种给法,二选一,同时传会报错:

给法什么时候用
priceIdStripe 后台建好的价格。改价、配优惠券和试用都不用动代码,正经 SaaS 用这个
内联 amount + currency + interval + name一个站点就一个套餐,不想去后台建东西

订阅的 webhook

和一次性支付是同一个入口,但要读的字段完全不同,所以验签结果上多了一个 subscription(非订阅事件是 undefined):

export async function webhook(_input: unknown, ctx: TalizenFuncContext) {
  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')

  const sub = event.subscription
  if (sub) {
    const { list } = ctx.db.query('subscriptions', {
      where: { subNo: sub.clientReferenceId },
      limit: 1,
    })
    const row = list[0]
    if (row) {
      ctx.db.update('subscriptions', row.id, {
        status: sub.active ? 'active' : 'inactive',
        stripeSubscriptionId: sub.subscriptionId,
        stripeCustomerId: sub.customerId,   // 存下来,开账单门户要用
        currentPeriodEnd: sub.currentPeriodEnd,
      })
    }
    ctx.db.insert('payment_events', { eventId: event.id, type: event.type })
  }

  return new Response('ok')
}

在 Stripe 后台至少勾上 invoice.paid、customer.subscription.updated、customer.subscription.deleted。checkout.session.completed 只覆盖第一期,续费不会再发它,只接它等于只有第一个月算钱。

字段平台做了什么
clientReferenceId从 invoice.parent.subscription_details.metadata 取回,续费时唯一能对回用户的东西
active发票类事件表示这期钱到账,并按 billingReason 过滤掉补开的一次性发票;订阅类事件表示状态是 active 或 trialing,试用期算有效
currentPeriodStart / End从 lines.data[0].period 或 items.data[0] 取,这两处路径在 Stripe API 版本之间搬过家
subscriptionId / customerId存进你自己的表,退订和开账单门户要用
priceId / amountPaid / billingReason同样是深路径,已归一化

退订与改付款方式

用 Stripe 托管的账单门户,一次调用拿一个页面地址。用户在那里改卡、看发票、退订,你不用自己写这些界面:

export function manageBilling(_input: unknown, ctx: TalizenFuncContext) {
  const user = ctx.auth.requireUser()
  const { list } = ctx.db.query('subscriptions', { where: { userId: String(user.id) }, limit: 1 })
  const row = list[0]
  if (!row?.stripeCustomerId) throw new Error('no subscription')

  const { url } = ctx.payment.stripe.billingPortalSession({
    customerId: row.stripeCustomerId,
    returnUrl: 'https://example.com/account',
  })
  return { url }
}

首次使用前要在 Stripe 后台 Settings → Billing → Customer portal 里启用一次。

查当前状态用 retrieveSubscription(subscriptionId),返回值里的 active 同样由平台算好(active 或 trialing),别自己判状态字符串。

退款与其余 API

退款、订阅、查 charge 等等用 call,平台代持密钥:

// 全额退款
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() 选:

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

平台不管的部分

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

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

边界

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

Render diagnostics