# BBpay > BBpay 帮网站和 AI 应用接入支付宝收款(人民币)。商户用一个 API Key 调 REST 接口创建订单,拿到支付宝收银台链接让买家付款,付款结果通过 Webhook 推送。BBpay 处理支付宝应用、签名验签和异步通知。 - Base URL: https://bbpay.uk - 人看的文档: https://bbpay.uk/docs - 两种 Key:bbp_test_ 走支付宝沙箱,不扣真钱,不用绑定;bbp_live_ 真实收款,商户必须先在后台「收款账户」绑定自己的支付宝(没绑定返回 403)。接口完全一样,订单和 Webhook 的 livemode 字段区分正式(true)和测试(false),只在 livemode=true 时给买家真实发货。 ## 接入步骤(给 AI 的建议做法) 1. API Key 放服务器端环境变量 `BBPAY_KEY`,Webhook 密钥放 `BBPAY_WEBHOOK_SECRET`。绝不能出现在前端代码里。 2. 后端写一个下单接口:调 `POST /api/vibe/orders`,把返回的 `pay_url` 给前端,前端 `location.href = pay_url`。 3. 后端写一个 Webhook 接口:用原始请求体验签,`payment.succeeded` 时按 `out_trade_no` 幂等地把订单标为已付款。 4. 买家跳回 `return_url` 时,调 `GET /api/vibe/orders/:out_trade_no` 确认状态再展示结果(跳回不等于付款成功)。 ## 鉴权 所有请求带请求头 `Authorization: Bearer `(Key 形如 bbp_test_xxx)。Key 无效返回 401;商户套餐到期返回 402。 ## POST /api/vibe/orders — 创建订单 请求体 JSON: | 参数 | 必填 | 说明 | | --- | --- | --- | | out_trade_no | 是 | 商户订单号,在你自己的订单里唯一即可(不同商户之间不冲突),只能用字母、数字、_、-,最长 64 位,不能以 SUB 开头 | | amount | 是 | 金额字符串,单位元,最多两位小数,0.01 ~ 100000000 | | subject | 是 | 商品名称,最长 256 字 | | return_url | 否 | 付款后跳回的地址 | | channel | 否 | page(电脑网页,默认)或 wap(手机网页,唤起支付宝 App) | | notify_url | 否 | 这一单的回调地址:结果改为以表单格式推到这里,不再推后台的 Webhook 地址(见「按单回调」) | ```bash curl -X POST https://bbpay.uk/api/vibe/orders \ -H "Authorization: Bearer $BBPAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "out_trade_no": "ORDER-1001", "amount": "9.90", "subject": "AI 简历润色 · 单次", "return_url": "https://your.site/thanks", "channel": "page" }' ``` ```ts // Next.js:app/api/checkout/route.ts export async function POST(req: Request) { const { orderId } = await req.json(); // 你自己系统里的订单号 const res = await fetch("https://bbpay.uk/api/vibe/orders", { method: "POST", headers: { Authorization: `Bearer ${process.env.BBPAY_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ out_trade_no: orderId, amount: "9.90", // 单位:元,字符串 subject: "AI 简历润色 · 单次", return_url: "https://your.site/thanks", // 付完跳回这里 channel: /Mobile/i.test(req.headers.get("user-agent") ?? "") ? "wap" : "page", }), }); const order = await res.json(); if (!res.ok) return Response.json({ error: order.error }, { status: res.status }); return Response.json({ pay_url: order.pay_url }); // 前端 location.href = pay_url } ``` ```python import os, requests res = requests.post( "https://bbpay.uk/api/vibe/orders", headers={"Authorization": f"Bearer {os.environ['BBPAY_KEY']}"}, json={ "out_trade_no": "ORDER-1001", "amount": "9.90", "subject": "AI 简历润色 · 单次", "return_url": "https://your.site/thanks", "channel": "page", }, timeout=10, ) order = res.json() res.raise_for_status() print(order["pay_url"]) # 把买家重定向到这个地址 ``` 返回: ```json { "out_trade_no": "ORDER-1001", "alipay_out_trade_no": "ORDER-1001", "livemode": false, "pay_url": "https://openapi-sandbox.dl.alipaydev.com/gateway.do?..." } ``` - 订单 15 分钟内未付自动关闭。 - 同一 out_trade_no 未付款、金额相同时重复调用,返回新的收银台链接。 - 订单号已付款、已关闭或金额不同时返回 409,需要换新订单号。 - alipay_out_trade_no 是发给支付宝的单号,一般和 out_trade_no 相同;若和其他商户撞号,BBpay 会另生成一个。去支付宝账单里对账时用它,其余接口、Webhook、查单都只认你的 out_trade_no。 ## GET /api/vibe/orders/:out_trade_no — 查询订单 未付款的订单会实时向支付宝补查。只能查自己 Key 创建的订单,否则 404。 ```bash curl https://bbpay.uk/api/vibe/orders/ORDER-1001 \ -H "Authorization: Bearer $BBPAY_KEY" ``` ```json { "out_trade_no": "ORDER-1001", "trade_no": "2026110222001498290509998118", "trade_status": "TRADE_SUCCESS", "total_amount": "9.90", "buyer_logon_id": "kve***@sandbox.com", "paid_at": "2026-11-02T08:30:10.000Z", "channel": "page", "livemode": false, "source": "notify_or_cache", "refunded_amount": "0.00", "refunds": [] } ``` trade_status:WAIT_BUYER_PAY 等待付款;TRADE_SUCCESS 付款成功;TRADE_FINISHED 交易完结(也算已付款);TRADE_CLOSED 超时未付或已关闭。 refunded_amount 是已退成功的总额,refunds 是退款记录(字段同下方退款接口的返回)。 ## POST /api/vibe/refunds — 退款 必须带 API Key。钱从商户自己的支付宝账户原路退给买家;bbp_test_ 订单在沙箱里退。 | 参数 | 必填 | 说明 | |---|---|---| | out_trade_no | 是 | 要退款的订单号(下单时传的) | | out_refund_no | 是 | 退款单号,同一笔订单里唯一,字母、数字、_、-,最长 64 位 | | refund_amount | 是 | 这次退款金额,单位元,最多两位小数 | | reason | 否 | 退款原因 | ```bash curl -X POST https://bbpay.uk/api/vibe/refunds \ -H "Authorization: Bearer $BBPAY_KEY" \ -H "Content-Type: application/json" \ -d '{"out_trade_no":"ORDER-1001","out_refund_no":"ORDER-1001-R1","refund_amount":"9.90","reason":"买家申请退款"}' ``` ```json { "out_trade_no": "ORDER-1001", "out_refund_no": "ORDER-1001-R1", "refund_amount": "9.90", "status": "succeeded", "reason": "买家申请退款", "error": null, "created_at": "2026-11-02T09:00:00.000Z", "succeeded_at": "2026-11-02T09:00:01.000Z", "refunded_amount": "9.90", "livemode": false } ``` - status:succeeded 成功;pending 处理中或结果未知;failed 没退成(HTTP 422,原因在 error)。 - pending、超时或网络错误时,用同一个 out_refund_no 和金额再调一次,不会重复退;或稍后查询订单看 refunds。 - 可以多次部分退款,累计不超过订单金额。全额退完后支付宝关闭交易,会再收到 order.closed。 - 退款成功推 refund.succeeded。TRADE_FINISHED(超过退款期限)不能退;沙箱只能全额退一次。 - 409:订单未付款 / 已关闭 / 已完结、累计超过订单金额,或同一退款单号金额不同。 ## 账号:GET /api/vibe/account 和 POST /api/vibe/redeem 测试 Key、正式 Key 都可以用,管的是 BBpay 月费,不涉及收款。 - GET /api/vibe/account → { name, plan: "basic"|"pro", expires_at: "YYYY-MM-DD"|null, grace_until, status: "active"|"grace"|"suspended", livemode }。expires_at 是北京时间日期(含当天),之后有 3 天宽限期;null = 长期有效。status=suspended 时下单返回 402。 - POST /api/vibe/redeem,body { "code": "BBP-XXXX-XXXX-XXXX" }(大小写、空格都能认)→ 200 { plan, months, expires_at, grace_until, previous_plan, plan_changed, redeemed_at, replayed }。兑换后套餐改成码上的套餐;有效期没过宽限期就从原到期日顺延,否则从今天算。已暂停的账号也能兑换。 - 同一账号重复兑换同一个码:不再加时间,原样返回 replayed: true。 - 长期有效的账号(expires_at 为 null)不能兑换:返回 409 { error, reason: "lifetime_account" },有效期和套餐都不变,码不会被占用,可以留给别的账号。 - 错误:400 格式不对;401 Key 无效;404 码不存在;409 已被别的账号用过、已作废,或账号是长期有效;429 一小时内失败太多次。 ## Webhook 商户在 https://bbpay.uk/dashboard/webhook 填 https 接收地址并获取 whsec_ 开头的密钥。 事件:payment.succeeded(付款成功)、order.closed(超时未付或关闭,全额退款后也会关闭)、refund.succeeded(一笔退款成功;data.refund = { refund_no, amount, reason },data.refunded_amount = 累计已退)、webhook.test(后台发送的测试事件)。 按单回调:下单带 notify_url 时,这一单改为向 notify_url POST application/x-www-form-urlencoded 表单(重试规则相同;需先在后台 Webhook 页保存过一次地址以获得密钥)。字段 event、out_trade_no、trade_no、trade_status、amount、livemode("true"/"false")、paid_at、notify_id、timestamp(秒)、sign;event=refund.succeeded 时另有 refund_no、refund_amount、refunded_amount。验签:去掉 sign 和空值字段,按字段名升序拼成 k=v&k=v(不做 URL 编码),HMAC-SHA256(whsec_ 密钥) 的小写 hex 与 sign 比对,拒绝 timestamp 超过 5 分钟的请求。返回 2xx 即算送达。 ```http POST https://your.site/api/bbpay-webhook BBpay-Signature: t=1793608212,v1=5f3c…e81a BBpay-Event: payment.succeeded BBpay-Delivery: evt_9Qm3xK2pLw8r { "id": "evt_9Qm3xK2pLw8r", "type": "payment.succeeded", "created": "2026-11-02T08:30:12.000Z", "livemode": false, "data": { "out_trade_no": "ORDER-1001", "trade_no": "2026110222001498290509998118", "trade_status": "TRADE_SUCCESS", "amount": "9.90", "subject": "AI 简历润色 · 单次", "channel": "page", "buyer_logon_id": "kve***@sandbox.com", "paid_at": "2026-11-02T08:30:10.000Z", "created_at": "2026-11-02T08:29:40.000Z" } } ``` 验签:`BBpay-Signature: t=,v1=`,v1 = HMAC-SHA256(密钥, "${t}.${原始请求体}") 的十六进制。必须用原始请求体;拒绝 t 与当前时间相差超过 300 秒的请求;按 data.out_trade_no 去重。 ```ts // Next.js:app/api/bbpay-webhook/route.ts import crypto from "node:crypto"; export async function POST(req: Request) { const body = await req.text(); // 一定要用原始请求体验签 const header = req.headers.get("bbpay-signature") ?? ""; const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("="))); const expected = crypto.createHmac("sha256", process.env.BBPAY_WEBHOOK_SECRET!) .update(`${t}.${body}`).digest("hex"); const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; // 拒绝 5 分钟前的请求,防重放 const ok = fresh && v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex")); if (!ok) return new Response("bad signature", { status: 400 }); const event = JSON.parse(body); if (event.type === "payment.succeeded") { // 按 out_trade_no 去重:同一事件可能送达多次 await markOrderPaid(event.data.out_trade_no, event.data.amount); } return new Response("ok"); // 8 秒内返回 2xx,否则会重试 } ``` ```python # Flask import hashlib, hmac, json, os, time from flask import Flask, request app = Flask(__name__) SECRET = os.environ["BBPAY_WEBHOOK_SECRET"].encode() @app.post("/api/bbpay-webhook") def bbpay_webhook(): body = request.get_data() # 原始请求体(bytes) header = request.headers.get("BBpay-Signature", "") parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv) t, v1 = parts.get("t", ""), parts.get("v1", "") expected = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest() if not t.isdigit() or abs(time.time() - int(t)) > 300 or not hmac.compare_digest(v1, expected): return "bad signature", 400 event = json.loads(body) if event["type"] == "payment.succeeded": mark_order_paid(event["data"]["out_trade_no"], event["data"]["amount"]) # 按订单号去重 return "ok" ``` 送达:8 秒内返回 2xx 算成功,不跟随重定向。失败后按 1 分钟、5 分钟、30 分钟、2 小时、6 小时、12 小时、24 小时重试,共 8 次,事件 id 不变。 ## 错误 响应体 `{ "error": "说明" }`,支付宝报错时另带 sub_code。 - 400 参数缺失或格式不对 - 401 API Key 无效或已作废 - 402 套餐已到期且过了 3 天宽限期 - 403 用正式 Key 下单但商户还没绑定支付宝 - 404 订单不存在 - 409 订单号已存在(金额不同或已付款 / 已关闭);退款时:订单不可退或累计超额 - 422 退款没退成(看 error,例如支付宝余额不足) - 502 支付宝返回错误(看 sub_code,可稍后重试) - 503 服务暂时不可用 收银台显示 insufficient-user-permissions(用户权限不足):商户绑定的支付宝没签约对应产品。channel=page 需要「电脑网站支付」,channel=wap 需要「手机网站支付」,在 b.alipay.com 产品中心签约后重新下单。 ## 开通正式收款 钱直接从买家进商户自己的支付宝,BBpay 不经手资金,所以商户的支付宝要自己具备收款产品: - 企业 / 个体户(有营业执照,账号名称和执照一致):在 b.alipay.com 产品中心签约「手机网站支付」(channel=wap)和「电脑网站支付」(channel=page),再在后台「收款账户」扫码绑定,新建 bbp_live_ Key。电脑网站支付通常要求有和账号主体一致的 ICP 备案,没有时可能只有试用期或限额。 - 个人(没有营业执照):支付宝一般不给个人开通网站支付。BBpay 正在接入支付宝「即刻收」,开通后个人实名认证即可收款;在此之前先用 bbp_test_ Key 完成接入。 - 只签了一种产品就只用对应的 channel。小程序 JSAPI 支付网站用不了,暂不支持。