Take Alipay payments with an integration
Connect the Alipay integration, then use ctx.payment.alipay in Func to start PC website payments and verify async notifications: the app private key stays on the server, while orders, amount checks and idempotent fulfilment stay in your own code.
Alipay is a managed integration: connect it once in the editor, then use ctx.payment.alipay in Func to start PC website payments and verify async notifications. The app private key and the AES key stay on the server — signing, verification and content encryption all happen there, so no secret ever appears in your code.
payment is a namespace, not a unified interface
Method names follow Alipay's own product shape. There is deliberately no cross-provider payment abstraction: parameter structures and callback semantics differ far too much (Stripe has Checkout Sessions, Alipay has signed form redirects), so a forced createCheckout() would only be a harder-to-read forwarder. Adding another provider later means adding ctx.payment.<provider>; existing code is untouched.
The split: the platform owns cryptography and credentials, your code owns money and goods.
| Guaranteed by the platform | Still yours to write |
|---|---|
| RSA2 signing, and notification verification over the raw body | Amounts come from a server-side product table |
Checking app_id / seller_id belong to this channel | The order table and its state |
| Optional AES content encryption | Idempotent fulfilment |
| Verifying OpenAPI responses against the raw response text | Comparing the amount with your own order |
| A real credential check when you save | — |
Connect Alipay
Collect the parameters in the Alipay console first:
- Open the app that receives the money and note its
APPID. - Under 开发设置 → 接口加签方式, generate an RSA2 key pair with the Alipay developer tool: keep the app private key, upload only the app public key, then copy the Alipay public key (支付宝公钥) shown in the same place.
- Note the Alipay user id (PID) of the receiving account; it must match
seller_idon notifications. - If you enabled 接口内容加密方式, copy that base64 AES key; skip it otherwise.
Then open Backend → Integrations in the editor, pick Alipay and fill the table below. Saving calls Alipay for real, so a wrong value fails immediately instead of when your first customer pays.
| Field | Value | Required |
|---|---|---|
| APPID | The APPID of your Alipay app | Yes |
| App private key | The private key you generated and keep — not the app public key. PKCS#8 or PKCS#1, with or without PEM headers | Yes |
| Alipay public key | The key Alipay gives you after you upload your app public key | Yes |
| Seller PID | Alipay user id of the receiving account | Yes |
| Async notification URL | A published https Func URL, e.g. https://example.com/func/alipay.notify | Yes |
| Return URL | Where the payer lands after paying; page experience only | No |
| AES key | The base64 string from 接口内容加密方式 | Only with content encryption |
| Gateway | Blank means production; sandbox needs its own gateway | Sandbox only |
What the save-time check covers
Saving runs alipay.trade.query against a random order id. That single call validates four things at once: the APPID exists and the interface is enabled; the app private key and the uploaded app public key are the same pair (Alipay accepts the signature); the Alipay public key is correct (it verifies Alipay's response signature); and the gateway matches the APPID (production vs. sandbox). With an AES key configured, that is checked too.
"Trade does not exist" is the success signal — the order id is deliberately one that cannot exist, so reaching that business error proves the request passed signing and permission checks.
| Error | What to fix |
|---|---|
isv.invalid-signature | The app private key does not match the app public key uploaded to Alipay |
isv.invalid-app-id | Wrong APPID, or a sandbox APPID paired with the production gateway |
| Response signature failed | Wrong Alipay public key (usually your own app public key by mistake) |
isv.decrypt-error | Wrong AES key |
isv.insufficient-isv-permissions | The app has not signed up for the product; enable it in the Alipay console |
The order table
Payment state lives in your own table. Create /platform/table/payment_orders.json:
{
"name": "Payment orders",
"desc": "Alipay order state",
"json_schema": {
"type": "object",
"properties": {
"userId": { "type": "string" },
"productId": { "type": "string" },
"amount": { "type": "string" },
"outTradeNo": { "type": "string" },
"status": { "type": "string", "enum": ["pending", "paid", "closed"] },
"alipayTradeNo": { "type": "string" },
"paidAt": { "type": "string" }
},
"required": ["userId", "productId", "amount", "outTradeNo", "status"]
}
}
Start a payment
Create /backend/func/alipay.ts:
import type { TalizenFuncContext } from 'talizen/func-runtime'
// The amount comes from a server-side product table; the browser only sends productId.
const PRODUCTS = {
starter: { subject: 'Starter plan', amount: '9.90' },
} 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')
// Generate the order id yourself and store the row first: it is both the
// primary key of your order table and the idempotency key of the notification.
const outTradeNo = 'C' + crypto.randomUUID().replace(/-/g, '')
ctx.db.insert('payment_orders', {
userId: String(user.id),
productId,
amount: product.amount,
outTradeNo,
status: 'pending',
})
const { payUrl } = ctx.payment.alipay.pageUrl({
outTradeNo,
subject: product.subject,
amount: product.amount,
})
return { outTradeNo, payUrl }
}
pageUrl returns { payUrl, outTradeNo, amount, appId }; send the browser to payUrl:
import { invoke } from 'talizen/func'
const { payUrl } = await invoke<{ payUrl: string }>('alipay.create', {
productId: 'starter',
})
window.location.assign(payUrl)
| Parameter | Notes |
|---|---|
outTradeNo | Required, up to 64 printable ASCII chars. The platform does not generate it: it is your order table's key and your idempotency key, so the row must exist before you redirect |
subject | Required, the product title |
amount | Required, in yuan with at most 2 decimals (9.9 is normalised to 9.90) |
body | Optional description |
returnUrl | Optional, overrides the configured return URL (page experience only) |
There is deliberately no notifyUrl override: payment facts must arrive through exactly one door, so the notification URL is configured once in the integration.
Receive the async notification
The notification URL you configured points at this method — https://example.com/func/alipay.notify maps to notify in /backend/func/alipay.ts:
export async function notify(_input: unknown, ctx: TalizenFuncContext) {
// Verification failures throw, so a forged notification never reaches the lines below.
// Do not catch it into a success: anything but 'success' makes Alipay retry.
const n = ctx.payment.alipay.verifyNotify(await ctx.request.text())
if (!n.paid) return new Response('success') // closed or other states
const { list } = ctx.db.query('payment_orders', {
where: { outTradeNo: n.outTradeNo },
limit: 1,
})
const order = list[0]
if (!order) return new Response('failure', { status: 400 })
if (order.amount !== n.totalAmount) return new Response('failure', { status: 400 })
if (order.status === 'paid') return new Response('success') // duplicate notification
ctx.db.update('payment_orders', order.id, {
status: 'paid',
alipayTradeNo: n.tradeNo,
paidAt: new Date().toISOString(),
})
// Granting entitlements must be idempotent on outTradeNo / tradeNo too.
return new Response('success')
}
verifyNotify must receive the raw form body (await ctx.request.text()). Never parse and re-serialise it: the signature covers the raw text, and rebuilding it breaks verification.
The platform does the RSA2 verification, checks that app_id and seller_id belong to this channel, and checks the required fields. Any failure throws rather than returning something you could mistake for falsy.
| Field | Notes |
|---|---|
paid | Covers both TRADE_SUCCESS and TRADE_FINISHED — use it instead of comparing strings yourself |
outTradeNo / tradeNo | Your order id / Alipay's trade id |
totalAmount | Amount paid; compare it with your own order |
tradeStatus | The raw trade status |
notifyId | Notification id, useful for de-duplication |
buyerId / gmtPayment / subject | Buyer id, payment time, title |
params | Every raw parameter; read passback_params and friends here |
Query, refund and other APIs
Use call for the remaining OpenAPI methods; the platform signs the request and verifies the response against its raw text:
// Query: for reconciliation, or to confirm once when the payer returns
const r = ctx.payment.alipay.call('alipay.trade.query', { out_trade_no: 'C0001' })
if (r.trade_status === 'TRADE_SUCCESS') { /* ... */ }
// Refund
ctx.payment.alipay.call('alipay.trade.refund', {
out_trade_no: 'C0001',
refund_amount: '9.90',
out_request_no: 'R0001', // refund request id; repeated refunds are idempotent on it
})
You get the verified business node (the contents of alipay_xxx_response); a business code other than 10000 throws. alipay.trade.page.pay cannot go through call — it is a redirect flow, so use pageUrl.
Several receiving accounts in one project
Add two Alipay integrations, give them different channel tags, and pick one with via():
ctx.payment.alipay.pageUrl({ ... }) // default channel, same as via('default')
ctx.payment.alipay.via('overseas').pageUrl({ ... }) // the other receiving account
ctx.payment.alipay.via('overseas').verifyNotify(raw) // notifications use the same channel
What the platform will not do for you
These are where payments actually go wrong, and the platform cannot help:
- Amounts come from a server-side product table; the browser only sends a
productId. Letting the browser send the amount lets it set the price. - Payment is only ever confirmed by a verified async notification. The
returnUrlis just a page the payer can open directly — never a proof of payment. - After verification, still check your local order and the amount, then whether
tradeNomatches what you recorded. - Fulfilment must be idempotent, keyed by
outTradeNo/tradeNo— notifications are retried.
Boundaries
- PC website payment in public-key mode only; WAP, in-app, face-to-face and certificate mode are not done yet.
- Payment integrations cannot enable "expose the key to Func code": the merchant private key never enters the sandbox, the platform signs instead. To do everything yourself, take the other road — set your own
ALIPAY_*variables under Backend → Env and write Func withfetch+crypto.subtle. The two paths do not interfere; see Alipay PC website payment with Func. - Sandbox and production use separate apps, keys and gateways. Before going live you must receive one real notification in production — nothing substitutes for that.
- The notification URL must be a published https address: Func on a preview domain will not receive live notifications.
