IntegrationsTake Alipay payments with an integration

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 platformStill yours to write
RSA2 signing, and notification verification over the raw bodyAmounts come from a server-side product table
Checking app_id / seller_id belong to this channelThe order table and its state
Optional AES content encryptionIdempotent fulfilment
Verifying OpenAPI responses against the raw response textComparing the amount with your own order
A real credential check when you save

Connect Alipay

Collect the parameters in the Alipay console first:

  1. Open the app that receives the money and note its APPID.
  2. 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.
  3. Note the Alipay user id (PID) of the receiving account; it must match seller_id on notifications.
  4. 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.

FieldValueRequired
APPIDThe APPID of your Alipay appYes
App private keyThe private key you generated and keep — not the app public key. PKCS#8 or PKCS#1, with or without PEM headersYes
Alipay public keyThe key Alipay gives you after you upload your app public keyYes
Seller PIDAlipay user id of the receiving accountYes
Async notification URLA published https Func URL, e.g. https://example.com/func/alipay.notifyYes
Return URLWhere the payer lands after paying; page experience onlyNo
AES keyThe base64 string from 接口内容加密方式Only with content encryption
GatewayBlank means production; sandbox needs its own gatewaySandbox 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.

ErrorWhat to fix
isv.invalid-signatureThe app private key does not match the app public key uploaded to Alipay
isv.invalid-app-idWrong APPID, or a sandbox APPID paired with the production gateway
Response signature failedWrong Alipay public key (usually your own app public key by mistake)
isv.decrypt-errorWrong AES key
isv.insufficient-isv-permissionsThe 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)
ParameterNotes
outTradeNoRequired, 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
subjectRequired, the product title
amountRequired, in yuan with at most 2 decimals (9.9 is normalised to 9.90)
bodyOptional description
returnUrlOptional, 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.

FieldNotes
paidCovers both TRADE_SUCCESS and TRADE_FINISHED — use it instead of comparing strings yourself
outTradeNo / tradeNoYour order id / Alipay's trade id
totalAmountAmount paid; compare it with your own order
tradeStatusThe raw trade status
notifyIdNotification id, useful for de-duplication
buyerId / gmtPayment / subjectBuyer id, payment time, title
paramsEvery 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 returnUrl is 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 tradeNo matches 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 with fetch + 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.

Render diagnostics