WeChat Pay Integration
A hands-on WeChat Pay integration guide for Realm Admins. Configure merchant credentials, create order-priced mappings, and collect payments through PC QR codes (Native) or in-WeChat checkout (JSAPI).
A hands-on WeChat Pay integration guide for Realm Admins. Configure merchant credentials, create order-priced mappings, and collect payments through PC QR codes (Native) or in-WeChat checkout (JSAPI).
Who this is for
Realm Admins who manage Herald's billing configuration, plus developers wiring the purchase flow into a web frontend. WeChat Pay v3 is the payment channel for users inside the WeChat ecosystem: PC browsers scan a QR code (Native), pages opened inside WeChat invoke the payment sheet directly (JSAPI). It sits next to Stripe, Creem, and IAP, with the same unified fulfillment but no shared checkout page.
Prerequisites
- The Herald admin console is accessible and you have an admin account with
billing.manage - You have created at least one Realm and one Client App
- A WeChat Pay merchant account (商户号) with API v3 enabled, verified on the WeChat Pay platform. Individual merchants cannot use the recurring features; this guide only needs unified orders, which every merchant type supports
- The
appIdof the WeChat app (Official Account, Mini Program, or Open Platform app) that your buyers log in with - Your Herald deployment is reachable from the public internet over HTTPS; WeChat calls your callback URL directly
Core concepts
WeChat Pay v3 has no hosted product catalog. Pricing is decided per order: when a purchase starts, Herald reads the price from the Entitlement Mapping and passes the amount to WeChat's unified order API. There is no product sync button for WeChat, and none is needed: a mapping with a price is the whole catalog.
The payment attempt is the same PaymentAttempt every other provider uses. What differs is the checkout surface:
- Native (PC): Herald creates the order and gets back a
code_url. Your frontend renders it as a QR code and polls the attempt status. The QR code is valid as long as the attempt (at most 2 hours); after that the user requests a new one. - JSAPI (inside WeChat): the page passes the buyer's
openidwith the order, Herald returns prepay parameters, and the frontend invokes the WeChat payment sheet throughWeixinJSBridge. No QR code, no redirect.
Fulfillment is webhook-driven, like Stripe and Creem. When payment succeeds, WeChat calls your callback URL; Herald verifies the signature with a cached platform certificate, decrypts the payload with your APIv3 key, checks the amount against the attempt, and fulfills by billing type. Duplicate callbacks are deduplicated by event ID, so WeChat's redeliveries never double-grant.
Subscriptions deserve one paragraph, because the semantics differ from Stripe: WeChat unified orders cannot auto-renew. A WeChat "subscription" is a non_renewing mapping: one payment, a fixed service period you configure (e.g. 30 days), a real subscription row the user can query, and an expiry that quietly ends the entitlement. No charge happens at expiry; the user buys again to extend. Auto-renewal (委托代扣) is a separate WeChat API family and is not part of this integration.
Step 1: Configure the WeChat Pay connection
- In the Herald admin console left menu, open Payment Providers
- Find WeChat Pay, click Configure
- Fill the form:
-
App ID (required): the
appIdbuyers use to log in -
Merchant ID (required): your WeChat Pay merchant number (商户号)
-
Merchant Private Key (required): the PEM private key you downloaded when applying for the merchant API certificate. Paste the full
-----BEGIN PRIVATE KEY-----block -
Certificate Serial No (required): the serial number of the merchant certificate, shown in the WeChat Pay console under API Safety
-
APIv3 Key (required): the 32-character key you set when enabling API v3
-
Notify URL (required): the public address WeChat calls on payment results:
https://your-herald-domain/api/third/pay/{realmId}/wechat/webhooksReplace
{realmId}with your realm ID. WeChat v3 takes the notify URL per order, not from the merchant console, so this config field is where it comes from -
Platform Public Key (optional): manual override for callback signature verification. Leave it empty in normal operation; Herald downloads and refreshes WeChat's platform certificates automatically
-
- Click Save
The private key and APIv3 key are stored as secrets and shown masked. When editing later, leave them empty to keep the old values. Deleting the configuration is refused while active WeChat subscriptions exist, same protection as every other provider.
One extra config key exists for testing: base_url (set through the realm config API, not the form). It points the v3 client at a different endpoint, such as a local mock during E2E runs or a WeChat sandbox proxy. Leave it unset in production.
Step 2: Create the Entitlement Mapping
WeChat mappings are created manually, like IAP mappings, because there is no catalog to sync.
- In the Herald admin console left menu, open Entitlement Mappings
- Click Create Mapping
- Fill the dialog:
- Provider:
WeChat Pay - External Product ID: a string of your choosing, e.g.
membership-30d. It goes into the WeChat order as the merchant product description anchor; the same ID can only be mapped once per Realm - Entitlement Key: something you recognize, e.g.
membership. Used in SDK queries and points policy - Credit Account: which pool the purchased credits land in
- Billing Type:
OneTimefor credit packs and buyouts,Non-renewingfor fixed-period memberships - Price / Currency (required for WeChat): the order amount in major units (
19.9charges ¥19.90) and a 3-letter ISO 4217 code (CNY) - Service Duration Days (non-renewing only): the fixed period, required, e.g.
30 - Points Per Period / Grant On Purchase: the credit policy, or none for a pure membership
- Provider:
- Submit
WeChat has no hosted catalog, so the order amount comes from the mapping rather than a provider Price — that's what the Price and Currency fields are for. WeChat is the only provider whose mappings accept a manual price: entering one on any other provider's mapping is rejected by the API. It is also a single-price channel — the purchase page shows one price with no currency switcher, whatever the user's currency preference is (see the Multi-Currency Pricing guide).
Mappings created before the price field existed (or written straight to the database) may carry none; those still sell — the order goes out with a 1-fen sentinel amount to satisfy the payment attempt constraint. If a test order shows 0.01 CNY, this is why.
Step 3: The purchase flow
Both scenes start from the same endpoint the other providers use:
POST /api/bill/{realmId}/purchase/payment-attempts
Authorization: Bearer <browser token with PurchaseInitiate scope>{
"targetType": "entitlement_mapping",
"targetId": "<mapping id>",
"paymentProvider": "wechat",
"paymentScene": "native"
}paymentScene is WeChat-only: native (the default) or jsapi. For jsapi, add the buyer's WeChat openid; the request is rejected with a 400 when it is missing. Other providers ignore the field.
Native (PC)
The response's paymentContext.wechatCodeUrl carries the QR content. Render it (the official purchase page uses a canvas QR component; any QR library works), start a countdown, and poll the attempt status through the existing attempt query API (PurchaseStatusRead scope). Polling stops on a terminal status. On expiry, stop polling and offer a "get a new QR code" action; on user cancellation, keep the attempt open and offer "pay again". Nothing is granted until the callback lands.
JSAPI (inside WeChat)
Pass "paymentScene": "jsapi" and the openid. The response's paymentContext.wechatJsapiParams carries {appId, timeStamp, nonceStr, package, signType, paySign} — invoke the payment sheet with:
WeixinJSBridge.invoke('getBrandWCPayRequest', wechatJsapiParams, (res) => {
// res.err_msg === 'get_brand_wcpay_request:ok' → payment sheet closed after success
})Treat that callback as a UI hint, not as proof of payment. The webhook remains the authoritative trigger for fulfillment; keep polling the attempt like the Native scene.
Where does the openid come from? Your side. The current contract on the official frontend: inside WeChat's built-in browser, the page reads the wechatOpenid URL search parameter and sends it as openid; outside WeChat, the page falls back to Native. When the parameter is missing inside WeChat, the purchase page refuses to place the order and asks the user to complete WeChat login first. If you build your own frontend, you decide how the openid arrives (login flow, server-side injection), but it must reach the order request as openid.
Step 4: Webhook and lifecycle
When payment completes, WeChat POSTs to your Notify URL with Wechatpay-Timestamp, Wechatpay-Nonce, Wechatpay-Signature, and Wechatpay-Serial headers. Herald then:
- Verifies the RSA-SHA256 signature with the platform certificate matching
Wechatpay-Serial(downloaded and cached automatically, refreshed before expiry) - Decrypts the resource with your APIv3 key (AES-256-GCM)
- Deduplicates by WeChat's event ID
- Locates the attempt through the merchant order number and compares the paid amount against the attempt amount
- On
trade_state = SUCCESS, marks the attempt succeeded and fulfills by billing type — credits into the mapped Credit Account, or a subscription row for non-renewing mappings
Anything wrong — bad signature, failed decryption, amount mismatch, unknown order — is rejected without touching the attempt. Herald does not retry failed processing itself; WeChat redelivers failed callbacks on its own schedule, and a manual replay entry exists for operators (same compensation path as Stripe and Creem webhooks).
Non-renewing memberships end on their own: the subscription row reaches its fixed end date and the entitlement lapses. There is no renewal webhook to wait for and no charge. The user re-purchases to extend, and the new purchase creates a fresh subscription row.
WeChat transactions do not enter Herald's invoice system. WeChat Pay has no invoice API for third parties, so unlike Stripe and Creem there are no external invoices to sync — see Invoice management.
Verify the result
- On Payment Providers, confirm WeChat Pay shows as configured
- On Entitlement Mappings, confirm the mapping row exists with the right billing type, price and currency filled in, and (for memberships) service duration days
- Open the purchase page on a PC, select the WeChat Pay product, and confirm the QR code renders with a countdown
- Scan with a WeChat account wired to your merchant sandbox, pay, and confirm the page flips to success and the credits or subscription appear
- Repeat the payment, then resend the same callback (or wait for a redelivery): the second run must not grant anything
- For a membership, confirm Subscriptions shows the
wechatpayment provider, thenon_renewingbilling type, and the end date exactlyservice_duration_daysout
Troubleshooting
Order created but shows 0.01 CNY
The mapping has no price set. Herald sends a 1-fen sentinel so the attempt row passes its amount > 0 constraint. Set the price (see Step 2) and place a new order — the old attempt keeps its original amount.
Callback rejected with signature verification failure
Either the platform certificate cache is stale (Herald refreshes it automatically; a manual platform_public_key override set earlier is now outdated), or the callback did not come from WeChat. If you set a platform public key while testing, clear it and let the automatic download take over.
Callback rejected with amount mismatch
The mapping's price changed between order creation and payment. The attempt remembers the original amount; WeChat charged the current one. The rejected callback is the safety net working as designed — reconcile manually, then re-issue the order.
JSAPI order returns 400 "openid is required"
paymentScene is jsapi but no openid reached the request. On the official frontend this means the wechatOpenid URL parameter is absent inside WeChat's browser; on your own frontend, check how you forward the openid.
QR code expired almost immediately
Attempt expiry is capped at 2 hours (WeChat's per-order limit). An attempt that expires at once usually means an old attempt for the same target was still open — close or let it lapse, then request a new QR code.
Callbacks never arrive
The Notify URL must be public HTTPS with the right realm ID, reachable from WeChat's servers. A base_url override left pointing at a local mock produces the same symptom from the other direction: orders succeed against the mock, but nothing real can pay them.
Checklist
- WeChat Pay configured on the Payment Providers page (App ID, Merchant ID, private key, certificate serial, APIv3 key, Notify URL)
- Notify URL is public HTTPS with the correct
{realmId}, and nobase_urloverride is set in production - Entitlement Mapping created per product (provider, product ID, entitlement key, billing type, credit account)
- Price and currency filled in the create dialog (verified by a test order's amount)
- Non-renewing mappings have Service Duration Days set
- One Native purchase completed end to end: QR rendered, payment made, attempt succeeded, credits or subscription visible
- A duplicate callback observed to grant nothing
- JSAPI path exercised with a valid openid, and once without it (order refused)