Migrating to WeChat Pay APIv3: the three days behind a 'signature error'
In October we migrated one of our storefronts from legacy WeChat Pay code to APIv3. The engineering itself is not complicated; the hard part was a seemingly mystical failure: every unified-order request returned "signature error, please check and try again", even though we had verified the signature string against the documentation three times. This post records the full three-day investigation so others can avoid the same detours.
The conclusion first: two keys, one confusion
WeChat Pay's "key" is actually two different things: the legacy APIv2 MD5 key (a 32-character string) and the APIv3 key (another string, used only for callback decryption and refunds — it never participates in order signing). Our old configuration table had both filled in, and the legacy code used the v2 one. The new v3 path requires the merchant certificate's private key for RSA-SHA256 signing — the APIv3 key has nothing to do with that step.
The trap was that on day one we assumed "signature error" meant the v3 key was wrong, so we kept rotating the two string keys. The error never moved. It was only on day two, after we set the keys aside and verified our own signature string against the public key extracted from apiclient_cert.pem, that we confirmed the signature string was fine — what was wrong was a key that should never have been involved in signing at all.
What the signature string actually consists of
An APIv3 request signature string is five lines, none optional:
- HTTP method (uppercase, e.g.
POST) - URL path including query string, excluding the domain
- Timestamp in seconds
- A random nonce
- The raw request body (empty string for GET, but the trailing newline must remain)
Sign this string with the merchant private key using SHA256withRSA, then Base64-encode. When we finally printed each segment to logs and compared line by line, we found the timestamp in one build was in milliseconds — three extra digits, enough for server-side verification to fail, while the API returned nothing more specific than "signature error".
Lesson: error messages only tell you something is wrong, never where. Printing and diffing each input is the only reliable debugging method.
Newer merchant accounts don't issue an APIv2 key at all
Another easy-to-miss fact: relatively new merchant accounts no longer issue APIv2 keys by default. If any v2 logic remains (older payment SDKs implement only v2), signature verification will fail no matter what you paste, because the key simply does not exist. The quick check: log into the merchant platform, open Account Center → API Security, and see whether the APIv2 key can still be set. If the entry is disabled or unsupported, a full v3 migration is mandatory.
Similarly, the certificate toolkit may include pub_key_xxx.pem alongside apiclient_cert.pem / apiclient_key.pem. That is the WeChat platform public key, used to verify callbacks — do not confuse it with the merchant certificate's public key. Some newer accounts run in "public key mode", where /v3/certificates returns 404 permanently; callback verification must then use the platform key file rather than the certificate-download endpoint.
Three pitfalls on the callback side
Once ordering worked, callbacks became disaster area two. The pitfalls, in order:
- Different string structure: callback verification signs "timestamp nonce raw body" — three lines, a completely separate construction from the five-line order signature. Reusing the ordering function guarantees failure.
- The APIv3 key finally matters here: the
resourcefield in the callback body is AES-256-GCM ciphertext derived from the APIv3 key. That is the only step where this key appears. - Always implement a query fallback: if verification or decryption fails for any reason, actively call the order-query API by merchant order number and update local state from its result. That endpoint needs no key at all. During key rotation, the fallback is what keeps orders from being stuck in "pending".
The configuration we settled on
After the migration we collapsed payment configuration into four fields: merchant ID, certificate path, private key path, and the APIv3 key. Every field is visible and editable in our admin panel, and the save routine preserves any field the form does not submit — otherwise a single accidental save wipes production keys, an incident far worse than a signature error.
Final step before launch: run a real 0.01 CNY payment through the entire loop, reconcile by out_trade_no query results, and keep logs for at least 90 days. Every line of every signature string and every verification result goes to disk. Those logs saved us again during a later key rotation.
Looking back, the biggest gain from those three days was not a working payment flow but a debugging habit: distrust your intuition about the word "key". Break it into "which key, used at which step" — and the problem is already half solved.