微信小程序虚拟支付接入:双签名对不上、iOS 没沙箱,这两处最容易卡
小程序内的虚拟商品必须走「小程序虚拟支付」,这是平台要求,不是可选项。下面按接入顺序把文档明确的部分和社区踩过的坑分开写清楚。
背景与适用范围
文档明确:小程序内提供的虚拟商品——虚拟货币、解锁功能、订阅内容、付费功能、打赏、购买虚拟礼物——购买和支付都需要接入虚拟支付。它使用一个独立的微信支付商户号,在 MP 后台的【虚拟支付】模块开通。
客户端只有两个 API:实物/服务用 wx.requestPayment,虚拟商品用 wx.requestVirtualPayment。设备路由上,Android / 鸿蒙 / Windows 走微信支付;iOS 自 2.x 起路由到 Apple 支付(IAP),底层对接苹果通道。这也是它存在的主要价值——过去 iOS 禁止小程序内虚拟商品用普通支付。
适用于 Android、iOS、鸿蒙、Windows 等终端,iOS 端需微信客户端 8.0.68 及以上。
个人主体另有一份独立文档「虚拟支付:个人」:要求服务类目含「工具」、已认证备案,全终端月支付限额 10 万元。
不适用场景:实物、线下服务、物流类订单继续走普通微信支付,两套体系并行。商户号不可混用,有开发者反馈原来的普通微信支付商户号不能直接用于虚拟支付。
开通与基础配置
文档明确的接入条件:已认证小程序;主体为企业 / 事业单位 / 个体工商户;主体信息完备。
入口是 MP 左边栏【虚拟支付】→【开通】。流程为:阅读协议 → 提交营业执照 / 提现账户 / 支付管理员 → 审核(一般 1–7 个工作日)→ 账户验证(支付管理员是法人可跳过)→ 扫码签约 → 再等约 1–2 个工作日 → 进入商户管理模块。
iOS 端有一个额外前置条件:必须配置「小程序简称」,因为 Apple 支付要求展示 display name(MP - 虚拟支付 - 基础配置)。没配简称,iOS 端开不了 Apple 支付。
基础配置页里能拿到:appid、offerid、appkey,以及沙箱 AppKey 和现网 AppKey 两套密钥——后面签名的 env 决定用哪一套,别用混。
代币配置:设置代币名称与兑换比例,一旦发布不可修改。道具管理:上传道具到开发版本、发布至现网版本,可编辑保存后发布,并能查看发货推送配置。道具、代币是 Android / iOS 双端互通的,配置一次两端可用。
双签名:paySig 与 signature
这是接入的第一个卡点。两套签名都是 HMAC-SHA256 后转十六进制:
paySig = to_hex(hmac_sha256(appKey, uri + '&' + signData))
signature = to_hex(hmac_sha256(sessionKey, signData))
appKey 按 env 选沙箱或现网(env=0 现网,env=1 沙箱)。
signData 的取值分两种:wx.* API 用基础库给的 signData 字段;服务端 API 用整个 POST body。
uri 也分两种:服务端 API 填接口路径且不带 query_string——即去掉 ? 及后面所有内容,例如 /xpay/query_user_balance;调 wx.requestVirtualPayment 时 uri 固定填 requestVirtualPayment。
sessionKey 来自 auth.code2Session。
官方给了 Python 的 calc_pay_sig / calc_signature 示例,并给了固定断言值用于自检:
uri = '/xpay/query_user_balance'
appkey = '12345'
post_body= '{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}'
→ pay_sig 应为 c37809f27c6d7fd1837ad2500a04512b66b34fd793a39a385fade56dca89a4b5
session_key = '9hAb/NEYUlkaMBEsmFgzig=='
→ signature 应为 089d9e8dc5d308977360c4b79ec600a93d736802802a807d634192328032f6c7
排查顺序按这个走:
- 先用写死参数确认自己的签名算法输出与示例结果完全一致;
- 核对
uri是否误带了 query; - 核对
post_body是否与真正发出去的 HTTP body 逐字节一致——不同语言的 JSON 序列化顺序、空格都会改变结果; - 核对
appkey是否与env对应。
有开发者反馈,post_body 的 JSON 序列化差异是最高频的坑。建议服务端把参与签名的字符串原样保存并直接作为请求体发出,不要序列化两次。
服务端接口
路径均以 /xpay/ 开头,文档明确。
- 代币:
/xpay/query_user_balance(查代币余额)、/xpay/currency_pay(扣减代币)、/xpay/cancel_currency_pay(代币支付退款)、/xpay/present_currency(代币赠送)。赠送当前不支持按单号查询,可重试至返回成功0或重复操作268490004。 - 道具:
/xpay/start_upload_goods、/xpay/query_upload_goods、/xpay/start_publish_goods、/xpay/query_publish_goods。每次仅支持一个道具,多个需分多次调用。 - 订单:
/xpay/query_order(查现金单,非代币单)、/xpay/refund_order(对 JSAPI 下的单退款,仅启动退款任务,需再用query_order轮询退款单状态直至完成)、/xpay/notify_provide_goods(通知已发货,只能通知现金单)、/xpay/start_download_order、/xpay/query_download_order。 - 账单:
/xpay/download_bill(按日汇总结算账单,首次调用触发生成下载 URL,之后间隔轮询取最终 URL)、/xpay/download_ios_settlement_bill(下载指定月份苹果 IAP 月账单)。 - 资金:
/xpay/create_withdraw_order、/xpay/query_withdraw_order、/xpay/query_biz_balance。 - 投诉 / 管控:
/xpay/get_complaint_list、/xpay/get_complaint_detail、/xpay/get_negotiation_history、/xpay/response_complaint、/xpay/complete_complaint、/xpay/upload_vp_file、/xpay/get_upload_file_sign、/xpay/query_punishment_reasons。 - 广告金:
/xpay/query_transfer_account、/xpay/query_adver_funds、/xpay/create_funds_bill、/xpay/bind_transfer_accout、/xpay/query_funds_bill、/xpay/query_recover_bill、/xpay/download_adverfunds_order。
消息推送与发货幂等
文档明确的推送 Event:
xpay_goods_deliver_notify:现金买道具支付成功xpay_coin_pay_notify:代币扣减成功xpay_refund_notify:退款完成xpay_complaint_notify:用户投诉xpay_wxpay_callback_notify:风控事件,due_diligence尽职调查 /punishment管控流水xpay_subscribe_ios_refund_query_notify:iOS 退款问询
应答格式:XML 用 0,JSON 用 {"ErrCode":0,"ErrMsg":"success"},返回空或 success 等价成功。应答不对微信会重推,最多 15 次,间隔 2/4/8/16… 秒。
发货推送字段:ToUserName(小程序原始 ID)、FromUserName(道具场景固定微信官方 openid)、CreateTime、MsgType=event、Event、OpenId、OutTradeNo(业务订单号)、Env(0 现网 / 1 沙箱)、WeChatPayInfo{MchOrderNo, TransactionId, PaidTime}、GoodsInfo{ProductId, Quantity, OrigPrice, ActualPrice, Attach}、TeamInfo{ActivityId, TeamId, TeamType, TeamAction}。
代币支付推送是同样的外壳加 CoinInfo{Quantity, OrigPrice, ActualPrice, Attach}。
退款推送字段:WxRefundId、MchRefundId、WxOrderId、MchOrderId、RefundFee、RetCode(0 成功)、RetMsg、RefundStartTimestamp、RefundSuccTimestamp、WxpayRefundTransactionId、RetryTimes。
风控通知字段:AppId、NickName、MerchantCode、MerchantCompanyName、BusinessTime、BusinessCode、BusinessState、Remark、EventType、RetryTimes。
关键一点:官方注明【用户支付成功】由 wx.requestVirtualPayment 的 success 回调触发,可能丢失(例如微信异常退出)。所以发货推送分支与发货轮询分支至少实现一个,两者结合更可靠。也就是说,客户端 success 不能当作发货依据,应以服务端推送或 query_order 轮询为准。
发货幂等:以 wx_order_id / OutTradeNo 作幂等键去重(个人文档的检查清单写的是「以 wx_order_id 去重」)。
错误码对照
来源为官方文档、官方错误码页与社区整理,接入以官方最新为准。
90010:signature签名错误,或用户登录态session_key已过期,需重新获取 code 刷新。90011:pay_sig签名错误。90012:订单号重复(相同bill_no但其他参数不一致)。90013:余额不足(小游戏 midas 文档口径)。90017:没有调用接口的权限。90018:参数错误,具体看errmsg。268490002:微信侧订单数据不存在,检查order_id、环境是否一致、用户是否通过微信 API 消费。268490004:重复操作,按幂等忽略。268490009:登录态已过期,需重新打开小程序取 code。
注意虚拟支付与小游戏 midas 的错误码口径略有差异,不要把一套码直接套到另一套上。
iOS 相关
文档明确:Apple 支付不支持沙箱环境,仅支持现网。iOS 端用户下单条件为 iPhone / iPad + iOS 15 及以上、微信 8.0.68 及以上、最低支付金额 1 元、仅支持中国大陆 App Store 账户。
Apple 支付不支持开发者主动向用户发起退款。用户在 App Store 申请退款后,Apple 会向开发者发起重复三次的退款问询;连续 3 次、3 秒内均未应答,微信平台向 Apple 返回「不确定」,决定权交给苹果。应答用 IosRefundQueryResponse:result_code 0 表示放过(建议退款)、1 表示拦截(拒绝退款),evidence 必填,作为决策凭据用于退款审计。若苹果最终退款成功,平台仍通过 xpay_refund_notify 通知。
以下几条有开发者反馈,未在官方文档明确,接入前自行验证:
- iOS 上
env=1沙箱调不起来,只能用env=0现网测试,会真实扣款,建议用低价商品测完走退款。 - iOS 有最低支付金额限制,
goodsPrice=1(0.01 元)会报错,一般不低于 1 元。 - MP 后台配置或修改道具后约 10 分钟生效,未生效时下单可能返回
-15014,改完价格立刻测容易误判成代码问题。
结算与对账
文档明确:结算周期 T+3,订单完成后资金冻结,3 日后分账。「待结算金额」指尚未分账(未扣技术服务费)的金额。
iOS 端:苹果在自然月结束后 45~60 天内结算,扣除 Apple 佣金后打给腾讯,腾讯再划转到虚拟支付账户,到账后可提现。
退款方面,支付时间 180 天以内的退款平台退还手续费,超过 180 天不退还。
对账用 /xpay/download_bill(按日汇总)与 /xpay/download_ios_settlement_bill(苹果月账单)。iOS 订单可在虚拟支付 - 交易订单页切换「普通支付 / Apple 支付」查看,也可用 query_order 查。
有开发者反馈:代币余额以微信侧为权威账本,本地余额只做缓存与展示,需与微信侧对账纠偏;且 query_user_balance 有调用频率限制,不宜每次查询都打一次。这一条未在官方文档明确。
官方文档:
个人主体见「虚拟支付:个人」文档;错误码可参考小游戏 midas.pay / midas.getBalance 文档中的 errCode 表: